diff --git a/.checkmarx/config.yml b/.checkmarx/config.yml index 18b9be6a7e4..e45e83fcac9 100644 --- a/.checkmarx/config.yml +++ b/.checkmarx/config.yml @@ -7,5 +7,6 @@ checkmarx: scan: configs: sast: + presetName: "BW ASA Premium" # Exclude spec files, and test specific files filter: "!*.spec.ts,!**/spec/**,!apps/desktop/native-messaging-test-runner/**" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bfad3f26281..e9c1f229a51 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -61,6 +61,7 @@ apps/web/src/app/billing @bitwarden/team-billing-dev libs/angular/src/billing @bitwarden/team-billing-dev libs/common/src/billing @bitwarden/team-billing-dev libs/billing @bitwarden/team-billing-dev +bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev ## Platform team files ## apps/browser/src/platform @bitwarden/team-platform-dev diff --git a/.github/renovate.json b/.github/renovate.json index bd9ea0da5c3..95fd2dc11e1 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -16,6 +16,10 @@ "matchManagers": ["cargo"], "commitMessagePrefix": "[deps] Platform:" }, + { + "groupName": "napi", + "matchPackageNames": ["napi", "napi-build", "napi-derive"] + }, { "matchPackageNames": ["typescript", "zone.js"], "matchUpdateTypes": ["major", "minor"], diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 2c28d0cb523..e73f882bb40 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -444,7 +444,10 @@ jobs: macos-build: name: MacOS Build - runs-on: macos-13 + # Note, this workflow is running on macOS 11 to maintain compatibility with macOS 10.15 Catalina, + # as the newer versions will case the native modules to be incompatible with older macOS systems + # This version should stay pinned until we drop support for macOS 10.15, or we drop the native modules + runs-on: macos-11 needs: setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} @@ -602,7 +605,10 @@ jobs: macos-package-github: name: MacOS Package GitHub Release Assets - runs-on: macos-13 + # Note, this workflow is running on macOS 11 to maintain compatibility with macOS 10.15 Catalina, + # as the newer versions will case the native modules to be incompatible with older macOS systems + # This version should stay pinned until we drop support for macOS 10.15, or we drop the native modules + runs-on: macos-11 needs: - browser-build - macos-build @@ -808,7 +814,10 @@ jobs: macos-package-mas: name: MacOS Package Prod Release Asset - runs-on: macos-13 + # Note, this workflow is running on macOS 11 to maintain compatibility with macOS 10.15 Catalina, + # as the newer versions will case the native modules to be incompatible with older macOS systems + # This version should stay pinned until we drop support for macOS 10.15, or we drop the native modules + runs-on: macos-11 needs: - browser-build - macos-build @@ -1006,7 +1015,10 @@ jobs: macos-package-dev: name: MacOS Package Dev Release Asset if: false # We need to look into how code signing works for dev - runs-on: macos-13 + # Note, this workflow is running on macOS 11 to maintain compatibility with macOS 10.15 Catalina, + # as the newer versions will case the native modules to be incompatible with older macOS systems + # This version should stay pinned until we drop support for macOS 10.15, or we drop the native modules + runs-on: macos-11 needs: - browser-build - macos-build diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index abd25387736..8576fb6760a 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -299,7 +299,7 @@ jobs: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" - - name: Trigger web vault deploy + - name: Trigger web vault deploy using GitHub Run ID uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} @@ -311,7 +311,7 @@ jobs: ref: 'main', inputs: { 'environment': 'USDEV', - 'branch-or-tag': 'main' + 'build-web-run-id': '${{ github.run_id }}' } }) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 2d784652a57..769e7005881 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -27,6 +27,10 @@ on: description: "Debug mode" type: boolean default: true + build-web-run-id: + description: "Build-web workflow Run ID to use for artifact download" + type: string + required: false workflow_call: inputs: @@ -46,6 +50,10 @@ on: description: "Debug mode" type: boolean default: true + build-web-run-id: + description: "Build-web workflow Run ID to use for artifact download" + type: string + required: false permissions: deployments: write @@ -168,7 +176,20 @@ jobs: env: _ENVIRONMENT_ARTIFACT: ${{ needs.setup.outputs.environment-artifact }} steps: + - name: 'Download latest cloud asset using GitHub Run ID: ${{ inputs.build-web-run-id }}' + if: ${{ inputs.build-web-run-id }} + uses: bitwarden/gh-actions/download-artifacts@main + id: download-latest-artifacts + continue-on-error: true + with: + workflow: build-web.yml + path: apps/web + workflow_conclusion: success + run_id: ${{ inputs.build-web-run-id }} + artifacts: ${{ env._ENVIRONMENT_ARTIFACT }} + - name: 'Download latest cloud asset from branch/tag: ${{ inputs.branch-or-tag }}' + if: ${{ !inputs.build-web-run-id }} uses: bitwarden/gh-actions/download-artifacts@main id: download-artifacts continue-on-error: true @@ -249,7 +270,20 @@ jobs: keyvault: ${{ needs.setup.outputs.retrieve-secrets-keyvault }} secrets: "sa-bitwarden-web-vault-name,sp-bitwarden-web-vault-password,sp-bitwarden-web-vault-appid,sp-bitwarden-web-vault-tenant" + - name: 'Download latest cloud asset using GitHub Run ID: ${{ inputs.build-web-run-id }}' + if: ${{ inputs.build-web-run-id }} + uses: bitwarden/gh-actions/download-artifacts@main + id: download-latest-artifacts + continue-on-error: true + with: + workflow: build-web.yml + path: apps/web + workflow_conclusion: success + run_id: ${{ inputs.build-web-run-id }} + artifacts: ${{ env._ENVIRONMENT_ARTIFACT }} + - name: 'Download cloud asset from branch/tag: ${{ inputs.branch-or-tag }}' + if: ${{ !inputs.build-web-run-id }} uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-web.yml diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index 20bffb956ec..b9e2d7a8c85 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -393,7 +393,10 @@ jobs: macos-build: name: MacOS Build - runs-on: macos-13 + # Note, this workflow is running on macOS 11 to maintain compatibility with macOS 10.15 Catalina, + # as the newer versions will case the native modules to be incompatible with older macOS systems + # This version should stay pinned until we drop support for macOS 10.15, or we drop the native modules + runs-on: macos-11 needs: setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }} @@ -522,7 +525,10 @@ jobs: macos-package-github: name: MacOS Package GitHub Release Assets - runs-on: macos-13 + # Note, this workflow is running on macOS 11 to maintain compatibility with macOS 10.15 Catalina, + # as the newer versions will case the native modules to be incompatible with older macOS systems + # This version should stay pinned until we drop support for macOS 10.15, or we drop the native modules + runs-on: macos-11 needs: - setup - macos-build @@ -732,7 +738,10 @@ jobs: macos-package-mas: name: MacOS Package Prod Release Asset - runs-on: macos-13 + # Note, this workflow is running on macOS 11 to maintain compatibility with macOS 10.15 Catalina, + # as the newer versions will case the native modules to be incompatible with older macOS systems + # This version should stay pinned until we drop support for macOS 10.15, or we drop the native modules + runs-on: macos-11 needs: - setup - macos-build diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index ea9e69226ad..878171cd172 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -10,8 +10,6 @@ on: pull_request_target: types: [opened, synchronize] -permissions: read-all - jobs: check-run: name: Check PR run @@ -22,6 +20,8 @@ jobs: runs-on: ubuntu-22.04 needs: check-run permissions: + contents: read + pull-requests: write security-events: write steps: @@ -43,7 +43,7 @@ jobs: additional_params: --report-format sarif --output-path . ${{ env.INCREMENTAL }} - name: Upload Checkmarx results to GitHub - uses: github/codeql-action/upload-sarif@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6 + uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9 with: sarif_file: cx_result.sarif @@ -51,6 +51,9 @@ jobs: name: Quality scan runs-on: ubuntu-22.04 needs: check-run + permissions: + contents: read + pull-requests: write steps: - name: Check out repo diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 5aec22926d5..246ca9a533d 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -367,21 +367,27 @@ jobs: id: set-final-version-output run: | if [[ "${{ steps.bump-browser-version-override.outcome }}" = "success" ]]; then - echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT + echo "version_browser=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT elif [[ "${{ steps.bump-browser-version-automatic.outcome }}" = "success" ]]; then - echo "version=${{ steps.calculate-next-browser-version.outputs.version }}" >> $GITHUB_OUTPUT - elif [[ "${{ steps.bump-cli-version-override.outcome }}" = "success" ]]; then - echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT + echo "version_browser=${{ steps.calculate-next-browser-version.outputs.version }}" >> $GITHUB_OUTPUT + fi + + if [[ "${{ steps.bump-cli-version-override.outcome }}" = "success" ]]; then + echo "version_cli=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT elif [[ "${{ steps.bump-cli-version-automatic.outcome }}" = "success" ]]; then - echo "version=${{ steps.calculate-next-cli-version.outputs.version }}" >> $GITHUB_OUTPUT - elif [[ "${{ steps.bump-desktop-version-override.outcome }}" = "success" ]]; then - echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT + echo "version_cli=${{ steps.calculate-next-cli-version.outputs.version }}" >> $GITHUB_OUTPUT + fi + + if [[ "${{ steps.bump-desktop-version-override.outcome }}" = "success" ]]; then + echo "version_desktop=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT elif [[ "${{ steps.bump-desktop-version-automatic.outcome }}" = "success" ]]; then - echo "version=${{ steps.calculate-next-desktop-version.outputs.version }}" >> $GITHUB_OUTPUT - elif [[ "${{ steps.bump-web-version-override.outcome }}" = "success" ]]; then - echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT + echo "version_desktop=${{ steps.calculate-next-desktop-version.outputs.version }}" >> $GITHUB_OUTPUT + fi + + if [[ "${{ steps.bump-web-version-override.outcome }}" = "success" ]]; then + echo "version_web=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT elif [[ "${{ steps.bump-web-version-automatic.outcome }}" = "success" ]]; then - echo "version=${{ steps.calculate-next-web-version.outputs.version }}" >> $GITHUB_OUTPUT + echo "version_web=${{ steps.calculate-next-web-version.outputs.version }}" >> $GITHUB_OUTPUT fi - name: Check if version changed diff --git a/apps/browser/.eslintrc.json b/apps/browser/.eslintrc.json new file mode 100644 index 00000000000..ba960511839 --- /dev/null +++ b/apps/browser/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "env": { + "browser": true, + "webextensions": true + }, + "overrides": [ + { + "files": ["src/**/*.ts"], + "excludedFiles": [ + "src/**/{content,popup,spec}/**/*.ts", + "src/**/autofill/{notification,overlay}/**/*.ts", + "src/**/autofill/**/{autofill-overlay-content,collect-autofill-content,dom-element-visibility,insert-autofill-content}.service.ts", + "src/**/*.spec.ts" + ], + "rules": { + "no-restricted-globals": [ + "error", + { + "name": "window", + "message": "The `window` object is not available in service workers and may not be available within the background script. Consider using `self`, `globalThis`, or another global property instead." + } + ] + } + } + ] +} diff --git a/apps/browser/package.json b/apps/browser/package.json index b03469bbb72..d06eadf58d4 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2024.3.0", + "version": "2024.3.1", "scripts": { "build": "webpack", "build:mv3": "cross-env MANIFEST_VERSION=3 webpack", diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 750b2e11707..ab4c4e5b6e6 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -709,7 +709,7 @@ "message": "Vis indstillinger i kontekstmenuen" }, "contextMenuItemDesc": { - "message": "Brug et sekundært klik for at få adgang til adgangskodegenerering og matchende logins til hjemmesiden." + "message": "Brug et sekundært klik for at tilgå adgangskodegenerering og matchende logins til webstedet." }, "contextMenuItemDescAlt": { "message": "Brug et sekundært klik for at få adgang til adgangskodegenerering og matchende logins til webstedet. Gælder alle indloggede konti." @@ -1033,7 +1033,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API server URL" + "message": "API-server URL" }, "webVaultUrl": { "message": "Web-boks server URL" @@ -1574,7 +1574,7 @@ "message": "Advarsel: Dette er en ikke-sikret HTTP side, og alle indsendte oplysninger kan potentielt ses og ændres af andre. Dette login blev oprindeligt gemt på en sikker (HTTPS) side." }, "insecurePageWarningFillPrompt": { - "message": "Do you still wish to fill this login?" + "message": "Ønsker dette login stadig udfyldt?" }, "autofillIframeWarning": { "message": "Formularen hostes af et andet domæne end URI'en for det gemte login. Vælg OK for at autoudfylde alligevel, eller Afbryd for at stoppe." @@ -1712,7 +1712,7 @@ "message": "Biometri mislykkedes" }, "biometricsFailedDesc": { - "message": "Biometri kan ikke fuldføres, overvej at bruge en hovedadgangskode eller logge ud og ind igen. Fortsætter problemet, kontakt Bitwarden-supporten." + "message": "Biometri kan ikke gennemføres. Overvej at bruge en hovedadgangskode eller at logge ud. Fortsætter problemet, kontakt Bitwarden-supporten." }, "nativeMessaginPermissionErrorTitle": { "message": "Tilladelse ikke givet" @@ -2027,7 +2027,7 @@ "message": "Minutter" }, "vaultTimeoutPolicyInEffect": { - "message": "Organisationspolitikker har sat maks. tilladt boks-timeout. til $HOURS$ time(r) og $MINUTES$ minut(ter).", + "message": "Organisationspolitikkerne har fastsat den maksimalt tilladte boks-timeout til $HOURS$ time(r) og $MINUTES$ minut(ter).", "placeholders": { "hours": { "content": "$1", @@ -2314,7 +2314,7 @@ "message": "Sådan autoudfyldes" }, "autofillSelectInfoWithCommand": { - "message": "Select an item from this screen, use the shortcut $COMMAND$, or explore other options in settings.", + "message": "Vælg et emne fra denne skærm, brug genvejen $COMMAND$ eller udforsk andre valgmuligheder i Indstillinger.", "placeholders": { "command": { "content": "$1", @@ -2323,7 +2323,7 @@ } }, "autofillSelectInfoWithoutCommand": { - "message": "Select an item from this screen, or explore other options in settings." + "message": "Vælg et emne fra denne skærm eller udforsk andre valgmuligheder i Indstillinger." }, "gotIt": { "message": "Forstået" @@ -2700,7 +2700,7 @@ } }, "launchDuoAndFollowStepsToFinishLoggingIn": { - "message": "Start DUO og følg trinene for at fuldføre indlogningen." + "message": "Start Duo og følg trinnene for at fuldføre indlogningen." }, "duoRequiredForAccount": { "message": "Duo-totrinsindlogning kræves for kontoen." @@ -2712,7 +2712,7 @@ "message": "Pop ud-udvidelse" }, "launchDuo": { - "message": "Start DUO" + "message": "Start Duo" }, "importFormatError": { "message": "Data er ikke korrekt formateret. Tjek importfilen og forsøg igen." diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 0defc8aa7c6..d802d277001 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2999,5 +2999,11 @@ "saveCipherAttemptFailed": { "message": "Error saving credentials. Check console for details.", "description": "Notification message for when saving credentials has failed." + }, + "removePasskey": { + "message": "Remove passkey" + }, + "passkeyRemoved": { + "message": "Passkey removed" } } diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 1b3e7d4e36f..44f6be8cef6 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -20,7 +20,7 @@ "message": "Masuk" }, "enterpriseSingleSignOn": { - "message": "Sistem Masuk Tunggal Perusahaan" + "message": "SSO Perusahaan" }, "cancel": { "message": "Batal" diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index a532ac50297..117c5be6b4b 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -2913,7 +2913,7 @@ "message": "Mudar de conta" }, "switchAccounts": { - "message": "Mudar de contas" + "message": "Mudar de conta" }, "switchToAccount": { "message": "Mudar para conta" diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index fec2dcc2d99..1c269640c83 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -1500,7 +1500,7 @@ "message": "无效 PIN 码。" }, "tooManyInvalidPinEntryAttemptsLoggingOut": { - "message": "无效的 PIN 输入尝试次数过多,正在退出登录。" + "message": "无效的 PIN 输入尝试次数过多,正在注销。" }, "unlockWithBiometrics": { "message": "使用生物识别解锁" @@ -1742,7 +1742,7 @@ "message": "Bitwarden 将不会询问是否为这些域名保存登录信息。您必须刷新页面才能使更改生效。" }, "excludedDomainsDescAlt": { - "message": "Bitwarden 不会询问保存所有已登录的账户的这些域名的登录信息。您必须刷新页面才能使更改生效。" + "message": "Bitwarden 不会询问保存所有已登录的账户的这些域名的登录信息。必须刷新页面才能使更改生效。" }, "excludedDomainsInvalidDomain": { "message": "$DOMAIN$ 不是一个有效的域名", @@ -2314,7 +2314,7 @@ "message": "如何自动填充" }, "autofillSelectInfoWithCommand": { - "message": "从此界面选择一个项目,使用快捷方式 $COMMAND$,或探索设置中的其他选项。", + "message": "从此界面选择一个项目,使用快捷键 $COMMAND$,或探索设置中的其他选项。", "placeholders": { "command": { "content": "$1", @@ -2335,10 +2335,10 @@ "message": "自动填充键盘快捷键" }, "autofillShortcutNotSet": { - "message": "未设置自动填充快捷方式。请在浏览器设置中更改此设置。" + "message": "未设置自动填充快捷键。可在浏览器的设置中更改它。" }, "autofillShortcutText": { - "message": "自动填充快捷方式为: $COMMAND$。在浏览器设置中更改此项。", + "message": "自动填充快捷键为:$COMMAND$。可在浏览器的设置中更改它。", "placeholders": { "command": { "content": "$1", @@ -2928,7 +2928,7 @@ "message": "已达到账户上限。请注销一个账户后再添加其他账户。" }, "active": { - "message": "已生效" + "message": "活动的" }, "locked": { "message": "已锁定" @@ -2961,7 +2961,7 @@ } }, "commonImportFormats": { - "message": "通用格式", + "message": "常规格式", "description": "Label indicating the most common import formats" }, "overrideDefaultBrowserAutofillTitle": { @@ -2969,7 +2969,7 @@ "description": "Dialog title facilitating the ability to override a chrome browser's default autofill behavior" }, "overrideDefaultBrowserAutofillDescription": { - "message": "忽略此设置可能会导致 Bitwarden 自动填充菜单与浏览器自带功能产生冲突。", + "message": "忽略此选项可能会导致 Bitwarden 自动填充菜单与浏览器自带功能产生冲突。", "description": "Dialog message facilitating the ability to override a chrome browser's default autofill behavior" }, "overrideDefaultBrowserAutoFillSettings": { diff --git a/apps/browser/src/auth/background/service-factories/auth-service.factory.ts b/apps/browser/src/auth/background/service-factories/auth-service.factory.ts index fa52ca6231c..f600efa18d5 100644 --- a/apps/browser/src/auth/background/service-factories/auth-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/auth-service.factory.ts @@ -23,13 +23,18 @@ import { stateServiceFactory, } from "../../../platform/background/service-factories/state-service.factory"; +import { AccountServiceInitOptions, accountServiceFactory } from "./account-service.factory"; +import { TokenServiceInitOptions, tokenServiceFactory } from "./token-service.factory"; + type AuthServiceFactoryOptions = FactoryOptions; export type AuthServiceInitOptions = AuthServiceFactoryOptions & + AccountServiceInitOptions & MessagingServiceInitOptions & CryptoServiceInitOptions & ApiServiceInitOptions & - StateServiceInitOptions; + StateServiceInitOptions & + TokenServiceInitOptions; export function authServiceFactory( cache: { authService?: AbstractAuthService } & CachedServices, @@ -41,10 +46,12 @@ export function authServiceFactory( opts, async () => new AuthService( + await accountServiceFactory(cache, opts), await messagingServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), await apiServiceFactory(cache, opts), await stateServiceFactory(cache, opts), + await tokenServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts b/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts index 5916f38441f..cac6f9bbe8a 100644 --- a/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts @@ -39,9 +39,13 @@ import { platformUtilsServiceFactory, } from "../../../platform/background/service-factories/platform-utils-service.factory"; import { - StateServiceInitOptions, - stateServiceFactory, -} from "../../../platform/background/service-factories/state-service.factory"; + StateProviderInitOptions, + stateProviderFactory, +} from "../../../platform/background/service-factories/state-provider.factory"; +import { + SecureStorageServiceInitOptions, + secureStorageServiceFactory, +} from "../../../platform/background/service-factories/storage-service.factory"; import { UserDecryptionOptionsServiceInitOptions, @@ -55,11 +59,12 @@ export type DeviceTrustCryptoServiceInitOptions = DeviceTrustCryptoServiceFactor CryptoFunctionServiceInitOptions & CryptoServiceInitOptions & EncryptServiceInitOptions & - StateServiceInitOptions & AppIdServiceInitOptions & DevicesApiServiceInitOptions & I18nServiceInitOptions & PlatformUtilsServiceInitOptions & + StateProviderInitOptions & + SecureStorageServiceInitOptions & UserDecryptionOptionsServiceInitOptions; export function deviceTrustCryptoServiceFactory( @@ -76,11 +81,12 @@ export function deviceTrustCryptoServiceFactory( await cryptoFunctionServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), await encryptServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), await appIdServiceFactory(cache, opts), await devicesApiServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), await platformUtilsServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), + await secureStorageServiceFactory(cache, opts), await userDecryptionOptionsServiceFactory(cache, opts), ), ); diff --git a/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts b/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts index 5fd1866c830..4a0dd07b322 100644 --- a/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts @@ -27,9 +27,9 @@ import { LogServiceInitOptions, } from "../../../platform/background/service-factories/log-service.factory"; import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../../platform/background/service-factories/state-service.factory"; + stateProviderFactory, + StateProviderInitOptions, +} from "../../../platform/background/service-factories/state-provider.factory"; import { TokenServiceInitOptions, tokenServiceFactory } from "./token-service.factory"; @@ -40,13 +40,13 @@ type KeyConnectorServiceFactoryOptions = FactoryOptions & { }; export type KeyConnectorServiceInitOptions = KeyConnectorServiceFactoryOptions & - StateServiceInitOptions & CryptoServiceInitOptions & ApiServiceInitOptions & TokenServiceInitOptions & LogServiceInitOptions & OrganizationServiceInitOptions & - KeyGenerationServiceInitOptions; + KeyGenerationServiceInitOptions & + StateProviderInitOptions; export function keyConnectorServiceFactory( cache: { keyConnectorService?: AbstractKeyConnectorService } & CachedServices, @@ -58,7 +58,6 @@ export function keyConnectorServiceFactory( opts, async () => new KeyConnectorService( - await stateServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), await apiServiceFactory(cache, opts), await tokenServiceFactory(cache, opts), @@ -66,6 +65,7 @@ export function keyConnectorServiceFactory( await organizationServiceFactory(cache, opts), await keyGenerationServiceFactory(cache, opts), opts.keyConnectorServiceOptions.logoutCallback, + await stateProviderFactory(cache, opts), ), ); } diff --git a/apps/browser/src/auth/background/service-factories/login-email-service.factory.ts b/apps/browser/src/auth/background/service-factories/login-email-service.factory.ts new file mode 100644 index 00000000000..6e98a9a8860 --- /dev/null +++ b/apps/browser/src/auth/background/service-factories/login-email-service.factory.ts @@ -0,0 +1,28 @@ +import { LoginEmailServiceAbstraction, LoginEmailService } from "@bitwarden/auth/common"; + +import { + CachedServices, + factory, + FactoryOptions, +} from "../../../platform/background/service-factories/factory-options"; +import { + stateProviderFactory, + StateProviderInitOptions, +} from "../../../platform/background/service-factories/state-provider.factory"; + +type LoginEmailServiceFactoryOptions = FactoryOptions; + +export type LoginEmailServiceInitOptions = LoginEmailServiceFactoryOptions & + StateProviderInitOptions; + +export function loginEmailServiceFactory( + cache: { loginEmailService?: LoginEmailServiceAbstraction } & CachedServices, + opts: LoginEmailServiceInitOptions, +): Promise { + return factory( + cache, + "loginEmailService", + opts, + async () => new LoginEmailService(await stateProviderFactory(cache, opts)), + ); +} diff --git a/apps/browser/src/auth/background/service-factories/token-service.factory.ts b/apps/browser/src/auth/background/service-factories/token-service.factory.ts index 25c30460f06..ba42998209e 100644 --- a/apps/browser/src/auth/background/service-factories/token-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/token-service.factory.ts @@ -1,6 +1,10 @@ import { TokenService as AbstractTokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; +import { + EncryptServiceInitOptions, + encryptServiceFactory, +} from "../../../platform/background/service-factories/encrypt-service.factory"; import { FactoryOptions, CachedServices, @@ -10,6 +14,14 @@ import { GlobalStateProviderInitOptions, globalStateProviderFactory, } from "../../../platform/background/service-factories/global-state-provider.factory"; +import { + KeyGenerationServiceInitOptions, + keyGenerationServiceFactory, +} from "../../../platform/background/service-factories/key-generation-service.factory"; +import { + LogServiceInitOptions, + logServiceFactory, +} from "../../../platform/background/service-factories/log-service.factory"; import { PlatformUtilsServiceInitOptions, platformUtilsServiceFactory, @@ -29,7 +41,10 @@ export type TokenServiceInitOptions = TokenServiceFactoryOptions & SingleUserStateProviderInitOptions & GlobalStateProviderInitOptions & PlatformUtilsServiceInitOptions & - SecureStorageServiceInitOptions; + SecureStorageServiceInitOptions & + KeyGenerationServiceInitOptions & + EncryptServiceInitOptions & + LogServiceInitOptions; export function tokenServiceFactory( cache: { tokenService?: AbstractTokenService } & CachedServices, @@ -45,6 +60,9 @@ export function tokenServiceFactory( await globalStateProviderFactory(cache, opts), (await platformUtilsServiceFactory(cache, opts)).supportsSecureStorage(), await secureStorageServiceFactory(cache, opts), + await keyGenerationServiceFactory(cache, opts), + await encryptServiceFactory(cache, opts), + await logServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/auth/popup/hint.component.ts b/apps/browser/src/auth/popup/hint.component.ts index a1f79cd457a..214a43efb71 100644 --- a/apps/browser/src/auth/popup/hint.component.ts +++ b/apps/browser/src/auth/popup/hint.component.ts @@ -2,8 +2,8 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { HintComponent as BaseHintComponent } from "@bitwarden/angular/auth/components/hint.component"; +import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -20,9 +20,9 @@ export class HintComponent extends BaseHintComponent { apiService: ApiService, logService: LogService, private route: ActivatedRoute, - loginService: LoginService, + loginEmailService: LoginEmailServiceAbstraction, ) { - super(router, i18nService, apiService, platformUtilsService, logService, loginService); + super(router, i18nService, apiService, platformUtilsService, logService, loginEmailService); super.onSuccessfulSubmit = async () => { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/apps/browser/src/auth/popup/home.component.html b/apps/browser/src/auth/popup/home.component.html index f70a4c6d030..8e23d96c49d 100644 --- a/apps/browser/src/auth/popup/home.component.html +++ b/apps/browser/src/auth/popup/home.component.html @@ -30,7 +30,7 @@ diff --git a/apps/browser/src/auth/popup/home.component.ts b/apps/browser/src/auth/popup/home.component.ts index 1360e6c8a6a..db83736be8a 100644 --- a/apps/browser/src/auth/popup/home.component.ts +++ b/apps/browser/src/auth/popup/home.component.ts @@ -1,14 +1,13 @@ import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { Subject, firstValueFrom, takeUntil } from "rxjs"; import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components/environment-selector.component"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; +import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { AccountSwitcherService } from "./account-switching/services/account-switcher.service"; @@ -29,38 +28,32 @@ export class HomeComponent implements OnInit, OnDestroy { constructor( protected platformUtilsService: PlatformUtilsService, - private stateService: StateService, private formBuilder: FormBuilder, private router: Router, private i18nService: I18nService, private environmentService: EnvironmentService, - private loginService: LoginService, + private loginEmailService: LoginEmailServiceAbstraction, private accountSwitcherService: AccountSwitcherService, ) {} async ngOnInit(): Promise { - let savedEmail = this.loginService.getEmail(); - const rememberEmail = this.loginService.getRememberEmail(); + const email = this.loginEmailService.getEmail(); + const rememberEmail = this.loginEmailService.getRememberEmail(); - if (savedEmail != null) { - this.formGroup.patchValue({ - email: savedEmail, - rememberEmail: rememberEmail, - }); + if (email != null) { + this.formGroup.patchValue({ email, rememberEmail }); } else { - savedEmail = await this.stateService.getRememberedEmail(); - if (savedEmail != null) { - this.formGroup.patchValue({ - email: savedEmail, - rememberEmail: true, - }); + const storedEmail = await firstValueFrom(this.loginEmailService.storedEmail$); + + if (storedEmail != null) { + this.formGroup.patchValue({ email: storedEmail, rememberEmail: true }); } } this.environmentSelector.onOpenSelfHostedSettings .pipe(takeUntil(this.destroyed$)) .subscribe(() => { - this.setFormValues(); + this.setLoginEmailValues(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(["environment"]); @@ -76,8 +69,9 @@ export class HomeComponent implements OnInit, OnDestroy { return this.accountSwitcherService.availableAccounts$; } - submit() { + async submit() { this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { this.platformUtilsService.showToast( "error", @@ -87,15 +81,12 @@ export class HomeComponent implements OnInit, OnDestroy { return; } - this.loginService.setEmail(this.formGroup.value.email); - this.loginService.setRememberEmail(this.formGroup.value.rememberEmail); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["login"], { queryParams: { email: this.formGroup.value.email } }); + this.setLoginEmailValues(); + await this.router.navigate(["login"], { queryParams: { email: this.formGroup.value.email } }); } - setFormValues() { - this.loginService.setEmail(this.formGroup.value.email); - this.loginService.setRememberEmail(this.formGroup.value.rememberEmail); + setLoginEmailValues() { + this.loginEmailService.setEmail(this.formGroup.value.email); + this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail); } } diff --git a/apps/browser/src/auth/popup/lock.component.ts b/apps/browser/src/auth/popup/lock.component.ts index f2c56a23aef..f232eca45a7 100644 --- a/apps/browser/src/auth/popup/lock.component.ts +++ b/apps/browser/src/auth/popup/lock.component.ts @@ -9,6 +9,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; @@ -62,6 +63,7 @@ export class LockComponent extends BaseLockComponent { pinCryptoService: PinCryptoServiceAbstraction, private routerService: BrowserRouterService, biometricStateService: BiometricStateService, + accountService: AccountService, ) { super( router, @@ -84,6 +86,7 @@ export class LockComponent extends BaseLockComponent { userVerificationService, pinCryptoService, biometricStateService, + accountService, ); this.successRoute = "/tabs/current"; this.isInitialLockScreen = (window as any).previousPopupUrl == null; diff --git a/apps/browser/src/auth/popup/login-via-auth-request.component.ts b/apps/browser/src/auth/popup/login-via-auth-request.component.ts index a22636389a7..52f311ce7b7 100644 --- a/apps/browser/src/auth/popup/login-via-auth-request.component.ts +++ b/apps/browser/src/auth/popup/login-via-auth-request.component.ts @@ -1,17 +1,18 @@ import { Location } from "@angular/common"; -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Component } from "@angular/core"; import { Router } from "@angular/router"; import { LoginViaAuthRequestComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/auth/components/login-via-auth-request.component"; import { AuthRequestServiceAbstraction, LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -28,10 +29,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv selector: "app-login-via-auth-request", templateUrl: "login-via-auth-request.component.html", }) -export class LoginViaAuthRequestComponent - extends BaseLoginWithDeviceComponent - implements OnInit, OnDestroy -{ +export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent { constructor( router: Router, cryptoService: CryptoService, @@ -47,11 +45,12 @@ export class LoginViaAuthRequestComponent anonymousHubService: AnonymousHubService, validationService: ValidationService, stateService: StateService, - loginService: LoginService, + loginEmailService: LoginEmailServiceAbstraction, syncService: SyncService, deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, authRequestService: AuthRequestServiceAbstraction, loginStrategyService: LoginStrategyServiceAbstraction, + accountService: AccountService, private location: Location, ) { super( @@ -69,10 +68,11 @@ export class LoginViaAuthRequestComponent anonymousHubService, validationService, stateService, - loginService, + loginEmailService, deviceTrustCryptoService, authRequestService, loginStrategyService, + accountService, ); super.onSuccessfulLogin = async () => { await syncService.fullSync(true); diff --git a/apps/browser/src/auth/popup/login.component.html b/apps/browser/src/auth/popup/login.component.html index f6ebb747f77..b24a25a0f1a 100644 --- a/apps/browser/src/auth/popup/login.component.html +++ b/apps/browser/src/auth/popup/login.component.html @@ -52,7 +52,7 @@ diff --git a/apps/browser/src/auth/popup/login.component.ts b/apps/browser/src/auth/popup/login.component.ts index 5c302455e66..ff0ee8a392d 100644 --- a/apps/browser/src/auth/popup/login.component.ts +++ b/apps/browser/src/auth/popup/login.component.ts @@ -5,9 +5,11 @@ import { firstValueFrom } from "rxjs"; import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/auth/components/login.component"; import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; -import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common"; +import { + LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, +} from "@bitwarden/auth/common"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -46,7 +48,7 @@ export class LoginComponent extends BaseLoginComponent { formBuilder: FormBuilder, formValidationErrorService: FormValidationErrorsService, route: ActivatedRoute, - loginService: LoginService, + loginEmailService: LoginEmailServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction, webAuthnLoginService: WebAuthnLoginServiceAbstraction, ) { @@ -66,7 +68,7 @@ export class LoginComponent extends BaseLoginComponent { formBuilder, formValidationErrorService, route, - loginService, + loginEmailService, ssoLoginService, webAuthnLoginService, ); @@ -77,8 +79,8 @@ export class LoginComponent extends BaseLoginComponent { this.showPasswordless = flagEnabled("showPasswordless"); if (this.showPasswordless) { - this.formGroup.controls.email.setValue(this.loginService.getEmail()); - this.formGroup.controls.rememberEmail.setValue(this.loginService.getRememberEmail()); + this.formGroup.controls.email.setValue(this.loginEmailService.getEmail()); + this.formGroup.controls.rememberEmail.setValue(this.loginEmailService.getRememberEmail()); // 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.validateEmail(); @@ -94,7 +96,7 @@ export class LoginComponent extends BaseLoginComponent { async launchSsoBrowser() { // Save off email for SSO await this.ssoLoginService.setSsoEmail(this.formGroup.value.email); - await this.loginService.saveEmailSettings(); + await this.loginEmailService.saveEmailSettings(); // Generate necessary sso params const passwordOptions: any = { type: "password", diff --git a/apps/browser/src/auth/popup/services/unauth-guard.service.ts b/apps/browser/src/auth/popup/services/unauth-guard.service.ts index 062239a7d36..0fbb4ac9bae 100644 --- a/apps/browser/src/auth/popup/services/unauth-guard.service.ts +++ b/apps/browser/src/auth/popup/services/unauth-guard.service.ts @@ -1,8 +1,5 @@ -import { Injectable } from "@angular/core"; - import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards"; -@Injectable() export class UnauthGuardService extends BaseUnauthGuardService { protected homepage = "tabs/current"; } diff --git a/apps/browser/src/auth/popup/sso.component.ts b/apps/browser/src/auth/popup/sso.component.ts index 430bd855f1d..228c7401fda 100644 --- a/apps/browser/src/auth/popup/sso.component.ts +++ b/apps/browser/src/auth/popup/sso.component.ts @@ -12,7 +12,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -44,7 +44,7 @@ export class SsoComponent extends BaseSsoComponent { environmentService: EnvironmentService, logService: LogService, userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - configService: ConfigServiceAbstraction, + configService: ConfigService, protected authService: AuthService, @Inject(WINDOW) private win: Window, ) { diff --git a/apps/browser/src/auth/popup/two-factor.component.ts b/apps/browser/src/auth/popup/two-factor.component.ts index da2c3482fdd..dd541f63f82 100644 --- a/apps/browser/src/auth/popup/two-factor.component.ts +++ b/apps/browser/src/auth/popup/two-factor.component.ts @@ -7,16 +7,16 @@ import { TwoFactorComponent as BaseTwoFactorComponent } from "@bitwarden/angular import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -57,9 +57,9 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { logService: LogService, twoFactorService: TwoFactorService, appIdService: AppIdService, - loginService: LoginService, + loginEmailService: LoginEmailServiceAbstraction, userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - configService: ConfigServiceAbstraction, + configService: ConfigService, ssoLoginService: SsoLoginServiceAbstraction, private dialogService: DialogService, @Inject(WINDOW) protected win: Window, @@ -78,7 +78,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { logService, twoFactorService, appIdService, - loginService, + loginEmailService, userDecryptionOptionsService, ssoLoginService, configService, diff --git a/apps/browser/src/autofill/background/web-request.background.ts b/apps/browser/src/autofill/background/web-request.background.ts index f4422e6d7f6..8cdfa0f0276 100644 --- a/apps/browser/src/autofill/background/web-request.background.ts +++ b/apps/browser/src/autofill/background/web-request.background.ts @@ -17,7 +17,7 @@ export default class WebRequestBackground { private authService: AuthService, ) { if (BrowserApi.isManifestVersion(2)) { - this.webRequest = (window as any).chrome.webRequest; + this.webRequest = chrome.webRequest; } this.isFirefox = platformUtilsService.isFirefox(); } diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index 760b833044e..596d6b7235e 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -30,6 +30,7 @@ import { authServiceFactory, AuthServiceInitOptions, } from "../../auth/background/service-factories/auth-service.factory"; +import { KeyConnectorServiceInitOptions } from "../../auth/background/service-factories/key-connector-service.factory"; import { userVerificationServiceFactory } from "../../auth/background/service-factories/user-verification-service.factory"; import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window"; import { autofillSettingsServiceFactory } from "../../autofill/background/service_factories/autofill-settings-service.factory"; @@ -78,7 +79,9 @@ export class ContextMenuClickedHandler { static async mv3Create(cachedServices: CachedServices) { const stateFactory = new StateFactory(GlobalState, Account); - const serviceOptions: AuthServiceInitOptions & CipherServiceInitOptions = { + const serviceOptions: AuthServiceInitOptions & + CipherServiceInitOptions & + KeyConnectorServiceInitOptions = { apiServiceOptions: { logoutCallback: NOT_IMPLEMENTED, }, diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts index 35b2269700d..e0af8014635 100644 --- a/apps/browser/src/autofill/content/autofill-init.spec.ts +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -1,582 +1,579 @@ -describe("a placeholder", () => { - expect(true).toBe(true); -}); +import { mock } from "jest-mock-extended"; -// import { mock } from "jest-mock-extended"; -// -// import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -// import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; -// -// import AutofillPageDetails from "../models/autofill-page-details"; -// import AutofillScript from "../models/autofill-script"; -// import AutofillOverlayContentService from "../services/autofill-overlay-content.service"; -// import { flushPromises, sendExtensionRuntimeMessage } from "../spec/testing-utils"; -// import { RedirectFocusDirection } from "../utils/autofill-overlay.enum"; -// -// import { AutofillExtensionMessage } from "./abstractions/autofill-init"; -// import AutofillInit from "./autofill-init"; -// -// describe("AutofillInit", () => { -// let autofillInit: AutofillInit; -// const autofillOverlayContentService = mock(); -// const originalDocumentReadyState = document.readyState; -// -// beforeEach(() => { -// chrome.runtime.connect = jest.fn().mockReturnValue({ -// onDisconnect: { -// addListener: jest.fn(), -// }, -// }); -// autofillInit = new AutofillInit(autofillOverlayContentService); -// }); -// -// afterEach(() => { -// jest.resetModules(); -// jest.clearAllMocks(); -// Object.defineProperty(document, "readyState", { -// value: originalDocumentReadyState, -// writable: true, -// }); -// }); -// -// describe("init", () => { -// it("sets up the extension message listeners", () => { -// jest.spyOn(autofillInit as any, "setupExtensionMessageListeners"); -// -// autofillInit.init(); -// -// expect(autofillInit["setupExtensionMessageListeners"]).toHaveBeenCalled(); -// }); -// -// it("triggers a collection of page details if the document is in a `complete` ready state", () => { -// jest.useFakeTimers(); -// Object.defineProperty(document, "readyState", { value: "complete", writable: true }); -// -// autofillInit.init(); -// jest.advanceTimersByTime(250); -// -// expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( -// { -// command: "bgCollectPageDetails", -// sender: "autofillInit", -// }, -// expect.any(Function), -// ); -// }); -// -// it("registers a window load listener to collect the page details if the document is not in a `complete` ready state", () => { -// jest.spyOn(window, "addEventListener"); -// Object.defineProperty(document, "readyState", { value: "loading", writable: true }); -// -// autofillInit.init(); -// -// expect(window.addEventListener).toHaveBeenCalledWith("load", expect.any(Function)); -// }); -// }); -// -// describe("setupExtensionMessageListeners", () => { -// it("sets up a chrome runtime on message listener", () => { -// jest.spyOn(chrome.runtime.onMessage, "addListener"); -// -// autofillInit["setupExtensionMessageListeners"](); -// -// expect(chrome.runtime.onMessage.addListener).toHaveBeenCalledWith( -// autofillInit["handleExtensionMessage"], -// ); -// }); -// }); -// -// describe("handleExtensionMessage", () => { -// let message: AutofillExtensionMessage; -// let sender: chrome.runtime.MessageSender; -// const sendResponse = jest.fn(); -// -// beforeEach(() => { -// message = { -// command: "collectPageDetails", -// tab: mock(), -// sender: "sender", -// }; -// sender = mock(); -// }); -// -// it("returns a undefined value if a extension message handler is not found with the given message command", () => { -// message.command = "unknownCommand"; -// -// const response = autofillInit["handleExtensionMessage"](message, sender, sendResponse); -// -// expect(response).toBe(undefined); -// }); -// -// it("returns a undefined value if the message handler does not return a response", async () => { -// const response1 = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); -// await flushPromises(); -// -// expect(response1).not.toBe(false); -// -// message.command = "removeAutofillOverlay"; -// message.fillScript = mock(); -// -// const response2 = autofillInit["handleExtensionMessage"](message, sender, sendResponse); -// await flushPromises(); -// -// expect(response2).toBe(undefined); -// }); -// -// it("returns a true value and calls sendResponse if the message handler returns a response", async () => { -// message.command = "collectPageDetailsImmediately"; -// const pageDetails: AutofillPageDetails = { -// title: "title", -// url: "http://example.com", -// documentUrl: "documentUrl", -// forms: {}, -// fields: [], -// collectedTimestamp: 0, -// }; -// jest -// .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") -// .mockResolvedValue(pageDetails); -// -// const response = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); -// await Promise.resolve(response); -// -// expect(response).toBe(true); -// expect(sendResponse).toHaveBeenCalledWith(pageDetails); -// }); -// -// describe("extension message handlers", () => { -// beforeEach(() => { -// autofillInit.init(); -// }); -// -// describe("collectPageDetails", () => { -// it("sends the collected page details for autofill using a background script message", async () => { -// const pageDetails: AutofillPageDetails = { -// title: "title", -// url: "http://example.com", -// documentUrl: "documentUrl", -// forms: {}, -// fields: [], -// collectedTimestamp: 0, -// }; -// const message = { -// command: "collectPageDetails", -// sender: "sender", -// tab: mock(), -// }; -// jest -// .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") -// .mockResolvedValue(pageDetails); -// -// sendExtensionRuntimeMessage(message, sender, sendResponse); -// await flushPromises(); -// -// expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ -// command: "collectPageDetailsResponse", -// tab: message.tab, -// details: pageDetails, -// sender: message.sender, -// }); -// }); -// }); -// -// describe("collectPageDetailsImmediately", () => { -// it("returns collected page details for autofill if set to send the details in the response", async () => { -// const pageDetails: AutofillPageDetails = { -// title: "title", -// url: "http://example.com", -// documentUrl: "documentUrl", -// forms: {}, -// fields: [], -// collectedTimestamp: 0, -// }; -// jest -// .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") -// .mockResolvedValue(pageDetails); -// -// sendExtensionRuntimeMessage( -// { command: "collectPageDetailsImmediately" }, -// sender, -// sendResponse, -// ); -// await flushPromises(); -// -// expect(autofillInit["collectAutofillContentService"].getPageDetails).toHaveBeenCalled(); -// expect(sendResponse).toBeCalledWith(pageDetails); -// expect(chrome.runtime.sendMessage).not.toHaveBeenCalled(); -// }); -// }); -// -// describe("fillForm", () => { -// let fillScript: AutofillScript; -// beforeEach(() => { -// fillScript = mock(); -// jest.spyOn(autofillInit["insertAutofillContentService"], "fillForm").mockImplementation(); -// }); -// -// it("skips calling the InsertAutofillContentService and does not fill the form if the url to fill is not equal to the current tab url", async () => { -// const fillScript = mock(); -// const message = { -// command: "fillForm", -// fillScript, -// pageDetailsUrl: "https://a-different-url.com", -// }; -// -// sendExtensionRuntimeMessage(message); -// await flushPromises(); -// -// expect(autofillInit["insertAutofillContentService"].fillForm).not.toHaveBeenCalledWith( -// fillScript, -// ); -// }); -// -// it("calls the InsertAutofillContentService to fill the form", async () => { -// sendExtensionRuntimeMessage({ -// command: "fillForm", -// fillScript, -// pageDetailsUrl: window.location.href, -// }); -// await flushPromises(); -// -// expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( -// fillScript, -// ); -// }); -// -// it("removes the overlay when filling the form", async () => { -// const blurAndRemoveOverlaySpy = jest.spyOn(autofillInit as any, "blurAndRemoveOverlay"); -// sendExtensionRuntimeMessage({ -// command: "fillForm", -// fillScript, -// pageDetailsUrl: window.location.href, -// }); -// await flushPromises(); -// -// expect(blurAndRemoveOverlaySpy).toHaveBeenCalled(); -// }); -// -// it("updates the isCurrentlyFilling property of the overlay to true after filling", async () => { -// jest.useFakeTimers(); -// jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling"); -// jest -// .spyOn(autofillInit["autofillOverlayContentService"], "focusMostRecentOverlayField") -// .mockImplementation(); -// -// sendExtensionRuntimeMessage({ -// command: "fillForm", -// fillScript, -// pageDetailsUrl: window.location.href, -// }); -// await flushPromises(); -// jest.advanceTimersByTime(300); -// -// expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(1, true); -// expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( -// fillScript, -// ); -// expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(2, false); -// }); -// -// it("skips attempting to focus the most recent field if the autofillOverlayContentService is not present", async () => { -// jest.useFakeTimers(); -// const newAutofillInit = new AutofillInit(undefined); -// newAutofillInit.init(); -// jest.spyOn(newAutofillInit as any, "updateOverlayIsCurrentlyFilling"); -// jest -// .spyOn(newAutofillInit["insertAutofillContentService"], "fillForm") -// .mockImplementation(); -// -// sendExtensionRuntimeMessage({ -// command: "fillForm", -// fillScript, -// pageDetailsUrl: window.location.href, -// }); -// await flushPromises(); -// jest.advanceTimersByTime(300); -// -// expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith( -// 1, -// true, -// ); -// expect(newAutofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( -// fillScript, -// ); -// expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).not.toHaveBeenNthCalledWith( -// 2, -// false, -// ); -// }); -// }); -// -// describe("openAutofillOverlay", () => { -// const message = { -// command: "openAutofillOverlay", -// data: { -// isFocusingFieldElement: true, -// isOpeningFullOverlay: true, -// authStatus: AuthenticationStatus.Unlocked, -// }, -// }; -// -// it("skips attempting to open the autofill overlay if the autofillOverlayContentService is not present", () => { -// const newAutofillInit = new AutofillInit(undefined); -// newAutofillInit.init(); -// -// sendExtensionRuntimeMessage(message); -// -// expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); -// }); -// -// it("opens the autofill overlay", () => { -// sendExtensionRuntimeMessage(message); -// -// expect( -// autofillInit["autofillOverlayContentService"].openAutofillOverlay, -// ).toHaveBeenCalledWith({ -// isFocusingFieldElement: message.data.isFocusingFieldElement, -// isOpeningFullOverlay: message.data.isOpeningFullOverlay, -// authStatus: message.data.authStatus, -// }); -// }); -// }); -// -// describe("closeAutofillOverlay", () => { -// beforeEach(() => { -// autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = false; -// autofillInit["autofillOverlayContentService"].isCurrentlyFilling = false; -// }); -// -// it("skips attempting to remove the overlay if the autofillOverlayContentService is not present", () => { -// const newAutofillInit = new AutofillInit(undefined); -// newAutofillInit.init(); -// jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); -// -// sendExtensionRuntimeMessage({ -// command: "closeAutofillOverlay", -// data: { forceCloseOverlay: false }, -// }); -// -// expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); -// }); -// -// it("removes the autofill overlay if the message flags a forced closure", () => { -// sendExtensionRuntimeMessage({ -// command: "closeAutofillOverlay", -// data: { forceCloseOverlay: true }, -// }); -// -// expect( -// autofillInit["autofillOverlayContentService"].removeAutofillOverlay, -// ).toHaveBeenCalled(); -// }); -// -// it("ignores the message if a field is currently focused", () => { -// autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = true; -// -// sendExtensionRuntimeMessage({ command: "closeAutofillOverlay" }); -// -// expect( -// autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, -// ).not.toHaveBeenCalled(); -// expect( -// autofillInit["autofillOverlayContentService"].removeAutofillOverlay, -// ).not.toHaveBeenCalled(); -// }); -// -// it("removes the autofill overlay list if the overlay is currently filling", () => { -// autofillInit["autofillOverlayContentService"].isCurrentlyFilling = true; -// -// sendExtensionRuntimeMessage({ command: "closeAutofillOverlay" }); -// -// expect( -// autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, -// ).toHaveBeenCalled(); -// expect( -// autofillInit["autofillOverlayContentService"].removeAutofillOverlay, -// ).not.toHaveBeenCalled(); -// }); -// -// it("removes the entire overlay if the overlay is not currently filling", () => { -// sendExtensionRuntimeMessage({ command: "closeAutofillOverlay" }); -// -// expect( -// autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, -// ).not.toHaveBeenCalled(); -// expect( -// autofillInit["autofillOverlayContentService"].removeAutofillOverlay, -// ).toHaveBeenCalled(); -// }); -// }); -// -// describe("addNewVaultItemFromOverlay", () => { -// it("will not add a new vault item if the autofillOverlayContentService is not present", () => { -// const newAutofillInit = new AutofillInit(undefined); -// newAutofillInit.init(); -// -// sendExtensionRuntimeMessage({ command: "addNewVaultItemFromOverlay" }); -// -// expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); -// }); -// -// it("will add a new vault item", () => { -// sendExtensionRuntimeMessage({ command: "addNewVaultItemFromOverlay" }); -// -// expect(autofillInit["autofillOverlayContentService"].addNewVaultItem).toHaveBeenCalled(); -// }); -// }); -// -// describe("redirectOverlayFocusOut", () => { -// const message = { -// command: "redirectOverlayFocusOut", -// data: { -// direction: RedirectFocusDirection.Next, -// }, -// }; -// -// it("ignores the message to redirect focus if the autofillOverlayContentService does not exist", () => { -// const newAutofillInit = new AutofillInit(undefined); -// newAutofillInit.init(); -// -// sendExtensionRuntimeMessage(message); -// -// expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); -// }); -// -// it("redirects the overlay focus", () => { -// sendExtensionRuntimeMessage(message); -// -// expect( -// autofillInit["autofillOverlayContentService"].redirectOverlayFocusOut, -// ).toHaveBeenCalledWith(message.data.direction); -// }); -// }); -// -// describe("updateIsOverlayCiphersPopulated", () => { -// const message = { -// command: "updateIsOverlayCiphersPopulated", -// data: { -// isOverlayCiphersPopulated: true, -// }, -// }; -// -// it("skips updating whether the ciphers are populated if the autofillOverlayContentService does note exist", () => { -// const newAutofillInit = new AutofillInit(undefined); -// newAutofillInit.init(); -// -// sendExtensionRuntimeMessage(message); -// -// expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); -// }); -// -// it("updates whether the overlay ciphers are populated", () => { -// sendExtensionRuntimeMessage(message); -// -// expect(autofillInit["autofillOverlayContentService"].isOverlayCiphersPopulated).toEqual( -// message.data.isOverlayCiphersPopulated, -// ); -// }); -// }); -// -// describe("bgUnlockPopoutOpened", () => { -// it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => { -// const newAutofillInit = new AutofillInit(undefined); -// newAutofillInit.init(); -// jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); -// -// sendExtensionRuntimeMessage({ command: "bgUnlockPopoutOpened" }); -// -// expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); -// expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled(); -// }); -// -// it("blurs the most recently focused feel and remove the autofill overlay", () => { -// jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField"); -// jest.spyOn(autofillInit as any, "removeAutofillOverlay"); -// -// sendExtensionRuntimeMessage({ command: "bgUnlockPopoutOpened" }); -// -// expect( -// autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField, -// ).toHaveBeenCalled(); -// expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); -// }); -// }); -// -// describe("bgVaultItemRepromptPopoutOpened", () => { -// it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => { -// const newAutofillInit = new AutofillInit(undefined); -// newAutofillInit.init(); -// jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); -// -// sendExtensionRuntimeMessage({ command: "bgVaultItemRepromptPopoutOpened" }); -// -// expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); -// expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled(); -// }); -// -// it("blurs the most recently focused feel and remove the autofill overlay", () => { -// jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField"); -// jest.spyOn(autofillInit as any, "removeAutofillOverlay"); -// -// sendExtensionRuntimeMessage({ command: "bgVaultItemRepromptPopoutOpened" }); -// -// expect( -// autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField, -// ).toHaveBeenCalled(); -// expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); -// }); -// }); -// -// describe("updateAutofillOverlayVisibility", () => { -// beforeEach(() => { -// autofillInit["autofillOverlayContentService"].autofillOverlayVisibility = -// AutofillOverlayVisibility.OnButtonClick; -// }); -// -// it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => { -// sendExtensionRuntimeMessage({ -// command: "updateAutofillOverlayVisibility", -// data: {}, -// }); -// -// expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( -// AutofillOverlayVisibility.OnButtonClick, -// ); -// }); -// -// it("updates the overlay visibility value", () => { -// const message = { -// command: "updateAutofillOverlayVisibility", -// data: { -// autofillOverlayVisibility: AutofillOverlayVisibility.Off, -// }, -// }; -// -// sendExtensionRuntimeMessage(message); -// -// expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( -// message.data.autofillOverlayVisibility, -// ); -// }); -// }); -// }); -// }); -// -// describe("destroy", () => { -// it("removes the extension message listeners", () => { -// autofillInit.destroy(); -// -// expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledWith( -// autofillInit["handleExtensionMessage"], -// ); -// }); -// -// it("destroys the collectAutofillContentService", () => { -// jest.spyOn(autofillInit["collectAutofillContentService"], "destroy"); -// -// autofillInit.destroy(); -// -// expect(autofillInit["collectAutofillContentService"].destroy).toHaveBeenCalled(); -// }); -// }); -// }); +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; + +import AutofillPageDetails from "../models/autofill-page-details"; +import AutofillScript from "../models/autofill-script"; +import AutofillOverlayContentService from "../services/autofill-overlay-content.service"; +import { flushPromises, sendExtensionRuntimeMessage } from "../spec/testing-utils"; +import { RedirectFocusDirection } from "../utils/autofill-overlay.enum"; + +import { AutofillExtensionMessage } from "./abstractions/autofill-init"; +import AutofillInit from "./autofill-init"; + +describe.skip("AutofillInit", () => { + let autofillInit: AutofillInit; + const autofillOverlayContentService = mock(); + const originalDocumentReadyState = document.readyState; + + beforeEach(() => { + chrome.runtime.connect = jest.fn().mockReturnValue({ + onDisconnect: { + addListener: jest.fn(), + }, + }); + autofillInit = new AutofillInit(autofillOverlayContentService); + window.IntersectionObserver = jest.fn(() => mock()); + }); + + afterEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + Object.defineProperty(document, "readyState", { + value: originalDocumentReadyState, + writable: true, + }); + }); + + describe("init", () => { + it("sets up the extension message listeners", () => { + jest.spyOn(autofillInit as any, "setupExtensionMessageListeners"); + + autofillInit.init(); + + expect(autofillInit["setupExtensionMessageListeners"]).toHaveBeenCalled(); + }); + + it("triggers a collection of page details if the document is in a `complete` ready state", () => { + jest.useFakeTimers(); + Object.defineProperty(document, "readyState", { value: "complete", writable: true }); + + autofillInit.init(); + jest.advanceTimersByTime(250); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith( + { + command: "bgCollectPageDetails", + sender: "autofillInit", + }, + expect.any(Function), + ); + }); + + it("registers a window load listener to collect the page details if the document is not in a `complete` ready state", () => { + jest.spyOn(window, "addEventListener"); + Object.defineProperty(document, "readyState", { value: "loading", writable: true }); + + autofillInit.init(); + + expect(window.addEventListener).toHaveBeenCalledWith("load", expect.any(Function)); + }); + }); + + describe("setupExtensionMessageListeners", () => { + it("sets up a chrome runtime on message listener", () => { + jest.spyOn(chrome.runtime.onMessage, "addListener"); + + autofillInit["setupExtensionMessageListeners"](); + + expect(chrome.runtime.onMessage.addListener).toHaveBeenCalledWith( + autofillInit["handleExtensionMessage"], + ); + }); + }); + + describe("handleExtensionMessage", () => { + let message: AutofillExtensionMessage; + let sender: chrome.runtime.MessageSender; + const sendResponse = jest.fn(); + + beforeEach(() => { + message = { + command: "collectPageDetails", + tab: mock(), + sender: "sender", + }; + sender = mock(); + }); + + it("returns a undefined value if a extension message handler is not found with the given message command", () => { + message.command = "unknownCommand"; + + const response = autofillInit["handleExtensionMessage"](message, sender, sendResponse); + + expect(response).toBe(undefined); + }); + + it("returns a undefined value if the message handler does not return a response", async () => { + const response1 = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); + await flushPromises(); + + expect(response1).not.toBe(false); + + message.command = "removeAutofillOverlay"; + message.fillScript = mock(); + + const response2 = autofillInit["handleExtensionMessage"](message, sender, sendResponse); + await flushPromises(); + + expect(response2).toBe(undefined); + }); + + it("returns a true value and calls sendResponse if the message handler returns a response", async () => { + message.command = "collectPageDetailsImmediately"; + const pageDetails: AutofillPageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + jest + .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") + .mockResolvedValue(pageDetails); + + const response = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); + await Promise.resolve(response); + + expect(response).toBe(true); + expect(sendResponse).toHaveBeenCalledWith(pageDetails); + }); + + describe("extension message handlers", () => { + beforeEach(() => { + autofillInit.init(); + }); + + describe("collectPageDetails", () => { + it("sends the collected page details for autofill using a background script message", async () => { + const pageDetails: AutofillPageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + const message = { + command: "collectPageDetails", + sender: "sender", + tab: mock(), + }; + jest + .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") + .mockResolvedValue(pageDetails); + + sendExtensionRuntimeMessage(message, sender, sendResponse); + await flushPromises(); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + command: "collectPageDetailsResponse", + tab: message.tab, + details: pageDetails, + sender: message.sender, + }); + }); + }); + + describe("collectPageDetailsImmediately", () => { + it("returns collected page details for autofill if set to send the details in the response", async () => { + const pageDetails: AutofillPageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + jest + .spyOn(autofillInit["collectAutofillContentService"], "getPageDetails") + .mockResolvedValue(pageDetails); + + sendExtensionRuntimeMessage( + { command: "collectPageDetailsImmediately" }, + sender, + sendResponse, + ); + await flushPromises(); + + expect(autofillInit["collectAutofillContentService"].getPageDetails).toHaveBeenCalled(); + expect(sendResponse).toBeCalledWith(pageDetails); + expect(chrome.runtime.sendMessage).not.toHaveBeenCalled(); + }); + }); + + describe("fillForm", () => { + let fillScript: AutofillScript; + beforeEach(() => { + fillScript = mock(); + jest.spyOn(autofillInit["insertAutofillContentService"], "fillForm").mockImplementation(); + }); + + it("skips calling the InsertAutofillContentService and does not fill the form if the url to fill is not equal to the current tab url", async () => { + const fillScript = mock(); + const message = { + command: "fillForm", + fillScript, + pageDetailsUrl: "https://a-different-url.com", + }; + + sendExtensionRuntimeMessage(message); + await flushPromises(); + + expect(autofillInit["insertAutofillContentService"].fillForm).not.toHaveBeenCalledWith( + fillScript, + ); + }); + + it("calls the InsertAutofillContentService to fill the form", async () => { + sendExtensionRuntimeMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + + expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( + fillScript, + ); + }); + + it("removes the overlay when filling the form", async () => { + const blurAndRemoveOverlaySpy = jest.spyOn(autofillInit as any, "blurAndRemoveOverlay"); + sendExtensionRuntimeMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + + expect(blurAndRemoveOverlaySpy).toHaveBeenCalled(); + }); + + it("updates the isCurrentlyFilling property of the overlay to true after filling", async () => { + jest.useFakeTimers(); + jest.spyOn(autofillInit as any, "updateOverlayIsCurrentlyFilling"); + jest + .spyOn(autofillInit["autofillOverlayContentService"], "focusMostRecentOverlayField") + .mockImplementation(); + + sendExtensionRuntimeMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + jest.advanceTimersByTime(300); + + expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(1, true); + expect(autofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( + fillScript, + ); + expect(autofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith(2, false); + }); + + it("skips attempting to focus the most recent field if the autofillOverlayContentService is not present", async () => { + jest.useFakeTimers(); + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "updateOverlayIsCurrentlyFilling"); + jest + .spyOn(newAutofillInit["insertAutofillContentService"], "fillForm") + .mockImplementation(); + + sendExtensionRuntimeMessage({ + command: "fillForm", + fillScript, + pageDetailsUrl: window.location.href, + }); + await flushPromises(); + jest.advanceTimersByTime(300); + + expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).toHaveBeenNthCalledWith( + 1, + true, + ); + expect(newAutofillInit["insertAutofillContentService"].fillForm).toHaveBeenCalledWith( + fillScript, + ); + expect(newAutofillInit["updateOverlayIsCurrentlyFilling"]).not.toHaveBeenNthCalledWith( + 2, + false, + ); + }); + }); + + describe("openAutofillOverlay", () => { + const message = { + command: "openAutofillOverlay", + data: { + isFocusingFieldElement: true, + isOpeningFullOverlay: true, + authStatus: AuthenticationStatus.Unlocked, + }, + }; + + it("skips attempting to open the autofill overlay if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + + sendExtensionRuntimeMessage(message); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("opens the autofill overlay", () => { + sendExtensionRuntimeMessage(message); + + expect( + autofillInit["autofillOverlayContentService"].openAutofillOverlay, + ).toHaveBeenCalledWith({ + isFocusingFieldElement: message.data.isFocusingFieldElement, + isOpeningFullOverlay: message.data.isOpeningFullOverlay, + authStatus: message.data.authStatus, + }); + }); + }); + + describe("closeAutofillOverlay", () => { + beforeEach(() => { + autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = false; + autofillInit["autofillOverlayContentService"].isCurrentlyFilling = false; + }); + + it("skips attempting to remove the overlay if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); + + sendExtensionRuntimeMessage({ + command: "closeAutofillOverlay", + data: { forceCloseOverlay: false }, + }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("removes the autofill overlay if the message flags a forced closure", () => { + sendExtensionRuntimeMessage({ + command: "closeAutofillOverlay", + data: { forceCloseOverlay: true }, + }); + + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlay, + ).toHaveBeenCalled(); + }); + + it("ignores the message if a field is currently focused", () => { + autofillInit["autofillOverlayContentService"].isFieldCurrentlyFocused = true; + + sendExtensionRuntimeMessage({ command: "closeAutofillOverlay" }); + + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, + ).not.toHaveBeenCalled(); + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlay, + ).not.toHaveBeenCalled(); + }); + + it("removes the autofill overlay list if the overlay is currently filling", () => { + autofillInit["autofillOverlayContentService"].isCurrentlyFilling = true; + + sendExtensionRuntimeMessage({ command: "closeAutofillOverlay" }); + + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, + ).toHaveBeenCalled(); + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlay, + ).not.toHaveBeenCalled(); + }); + + it("removes the entire overlay if the overlay is not currently filling", () => { + sendExtensionRuntimeMessage({ command: "closeAutofillOverlay" }); + + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlayList, + ).not.toHaveBeenCalled(); + expect( + autofillInit["autofillOverlayContentService"].removeAutofillOverlay, + ).toHaveBeenCalled(); + }); + }); + + describe("addNewVaultItemFromOverlay", () => { + it("will not add a new vault item if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + + sendExtensionRuntimeMessage({ command: "addNewVaultItemFromOverlay" }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("will add a new vault item", () => { + sendExtensionRuntimeMessage({ command: "addNewVaultItemFromOverlay" }); + + expect(autofillInit["autofillOverlayContentService"].addNewVaultItem).toHaveBeenCalled(); + }); + }); + + describe("redirectOverlayFocusOut", () => { + const message = { + command: "redirectOverlayFocusOut", + data: { + direction: RedirectFocusDirection.Next, + }, + }; + + it("ignores the message to redirect focus if the autofillOverlayContentService does not exist", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + + sendExtensionRuntimeMessage(message); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("redirects the overlay focus", () => { + sendExtensionRuntimeMessage(message); + + expect( + autofillInit["autofillOverlayContentService"].redirectOverlayFocusOut, + ).toHaveBeenCalledWith(message.data.direction); + }); + }); + + describe("updateIsOverlayCiphersPopulated", () => { + const message = { + command: "updateIsOverlayCiphersPopulated", + data: { + isOverlayCiphersPopulated: true, + }, + }; + + it("skips updating whether the ciphers are populated if the autofillOverlayContentService does note exist", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + + sendExtensionRuntimeMessage(message); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + }); + + it("updates whether the overlay ciphers are populated", () => { + sendExtensionRuntimeMessage(message); + + expect(autofillInit["autofillOverlayContentService"].isOverlayCiphersPopulated).toEqual( + message.data.isOverlayCiphersPopulated, + ); + }); + }); + + describe("bgUnlockPopoutOpened", () => { + it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); + + sendExtensionRuntimeMessage({ command: "bgUnlockPopoutOpened" }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled(); + }); + + it("blurs the most recently focused feel and remove the autofill overlay", () => { + jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField"); + jest.spyOn(autofillInit as any, "removeAutofillOverlay"); + + sendExtensionRuntimeMessage({ command: "bgUnlockPopoutOpened" }); + + expect( + autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField, + ).toHaveBeenCalled(); + expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); + }); + }); + + describe("bgVaultItemRepromptPopoutOpened", () => { + it("skips attempting to blur and remove the overlay if the autofillOverlayContentService is not present", () => { + const newAutofillInit = new AutofillInit(undefined); + newAutofillInit.init(); + jest.spyOn(newAutofillInit as any, "removeAutofillOverlay"); + + sendExtensionRuntimeMessage({ command: "bgVaultItemRepromptPopoutOpened" }); + + expect(newAutofillInit["autofillOverlayContentService"]).toBe(undefined); + expect(newAutofillInit["removeAutofillOverlay"]).not.toHaveBeenCalled(); + }); + + it("blurs the most recently focused feel and remove the autofill overlay", () => { + jest.spyOn(autofillInit["autofillOverlayContentService"], "blurMostRecentOverlayField"); + jest.spyOn(autofillInit as any, "removeAutofillOverlay"); + + sendExtensionRuntimeMessage({ command: "bgVaultItemRepromptPopoutOpened" }); + + expect( + autofillInit["autofillOverlayContentService"].blurMostRecentOverlayField, + ).toHaveBeenCalled(); + expect(autofillInit["removeAutofillOverlay"]).toHaveBeenCalled(); + }); + }); + + describe("updateAutofillOverlayVisibility", () => { + beforeEach(() => { + autofillInit["autofillOverlayContentService"].autofillOverlayVisibility = + AutofillOverlayVisibility.OnButtonClick; + }); + + it("skips attempting to update the overlay visibility if the autofillOverlayVisibility data value is not present", () => { + sendExtensionRuntimeMessage({ + command: "updateAutofillOverlayVisibility", + data: {}, + }); + + expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( + AutofillOverlayVisibility.OnButtonClick, + ); + }); + + it("updates the overlay visibility value", () => { + const message = { + command: "updateAutofillOverlayVisibility", + data: { + autofillOverlayVisibility: AutofillOverlayVisibility.Off, + }, + }; + + sendExtensionRuntimeMessage(message); + + expect(autofillInit["autofillOverlayContentService"].autofillOverlayVisibility).toEqual( + message.data.autofillOverlayVisibility, + ); + }); + }); + }); + }); + + describe("destroy", () => { + it("removes the extension message listeners", () => { + autofillInit.destroy(); + + expect(chrome.runtime.onMessage.removeListener).toHaveBeenCalledWith( + autofillInit["handleExtensionMessage"], + ); + }); + + it("destroys the collectAutofillContentService", () => { + jest.spyOn(autofillInit["collectAutofillContentService"], "destroy"); + + autofillInit.destroy(); + + expect(autofillInit["collectAutofillContentService"].destroy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/autofill/content/autofiller.ts b/apps/browser/src/autofill/content/autofiller.ts index 5f43023d8bd..0ca9d37187a 100644 --- a/apps/browser/src/autofill/content/autofiller.ts +++ b/apps/browser/src/autofill/content/autofiller.ts @@ -10,7 +10,7 @@ function loadAutofiller() { let pageHref: string = null; let filledThisHref = false; let delayFillTimeout: number; - let doFillInterval: NodeJS.Timeout; + let doFillInterval: number | NodeJS.Timeout; const handleExtensionDisconnect = () => { clearDoFillInterval(); clearDelayFillTimeout(); diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index 5d28bf83976..a730ee1ebac 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -139,7 +139,7 @@ function initNotificationBar(message: NotificationBarWindowMessage) { }); }); - window.addEventListener("resize", adjustHeight); + globalThis.addEventListener("resize", adjustHeight); adjustHeight(); } @@ -384,7 +384,7 @@ function setupLogoLink(i18n: Record) { function setNotificationBarTheme() { let theme = notificationBarIframeInitData.theme; if (theme === ThemeType.System) { - theme = window.matchMedia("(prefers-color-scheme: dark)").matches + theme = globalThis.matchMedia("(prefers-color-scheme: dark)").matches ? ThemeType.Dark : ThemeType.Light; } @@ -393,5 +393,5 @@ function setNotificationBarTheme() { } function postMessageToParent(message: NotificationBarWindowMessage) { - window.parent.postMessage(message, windowMessageOrigin || "*"); + globalThis.parent.postMessage(message, windowMessageOrigin || "*"); } diff --git a/apps/browser/src/autofill/overlay/iframe-content/autofill-overlay-iframe.service.ts b/apps/browser/src/autofill/overlay/iframe-content/autofill-overlay-iframe.service.ts index 0ec7db131c5..b7a6f2a39ed 100644 --- a/apps/browser/src/autofill/overlay/iframe-content/autofill-overlay-iframe.service.ts +++ b/apps/browser/src/autofill/overlay/iframe-content/autofill-overlay-iframe.service.ts @@ -211,7 +211,7 @@ class AutofillOverlayIframeService implements AutofillOverlayIframeServiceInterf let borderColor: string; let verifiedTheme = theme; if (verifiedTheme === ThemeType.System) { - verifiedTheme = window.matchMedia("(prefers-color-scheme: dark)").matches + verifiedTheme = globalThis.matchMedia("(prefers-color-scheme: dark)").matches ? ThemeType.Dark : ThemeType.Light; } diff --git a/apps/browser/src/autofill/overlay/pages/list/autofill-overlay-list.ts b/apps/browser/src/autofill/overlay/pages/list/autofill-overlay-list.ts index 305a230e5cf..8d4fa724afe 100644 --- a/apps/browser/src/autofill/overlay/pages/list/autofill-overlay-list.ts +++ b/apps/browser/src/autofill/overlay/pages/list/autofill-overlay-list.ts @@ -19,7 +19,7 @@ class AutofillOverlayList extends AutofillOverlayPageElement { private ciphers: OverlayCipherData[] = []; private ciphersList: HTMLUListElement; private cipherListScrollIsDebounced = false; - private cipherListScrollDebounceTimeout: NodeJS.Timeout; + private cipherListScrollDebounceTimeout: number | NodeJS.Timeout; private currentCipherIndex = 0; private readonly showCiphersPerPage = 6; private readonly overlayListWindowMessageHandlers: OverlayListWindowMessageHandlers = { diff --git a/apps/browser/src/autofill/services/abstractions/autofill.service.ts b/apps/browser/src/autofill/services/abstractions/autofill.service.ts index 77a5f982fd9..54a91a51764 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill.service.ts @@ -15,7 +15,7 @@ export interface PageDetail { export interface AutoFillOptions { cipher: CipherView; pageDetails: PageDetail[]; - doc?: typeof window.document; + doc?: typeof self.document; tab: chrome.tabs.Tab; skipUsernameOnlyFill?: boolean; onlyEmptyFields?: boolean; diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts index 79b9fe3747a..32480409026 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts @@ -33,7 +33,7 @@ describe("a placeholder", () => { // // const defaultWindowReadyState = document.readyState; // const defaultDocumentVisibilityState = document.visibilityState; -// describe("AutofillOverlayContentService", () => { +// describe.skip("AutofillOverlayContentService", () => { // let autofillOverlayContentService: AutofillOverlayContentService; // let sendExtensionMessageSpy: jest.SpyInstance; // @@ -177,12 +177,10 @@ describe("a placeholder", () => { // autofillFieldData = mock(); // }); // -// it("ignores fields that are readonly", () => { +// it("ignores fields that are readonly", async () => { // autofillFieldData.readonly = true; // -// // 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 -// autofillOverlayContentService.setupAutofillOverlayListenerOnField( +// await autofillOverlayContentService.setupAutofillOverlayListenerOnField( // autofillFieldElement, // autofillFieldData, // ); @@ -190,12 +188,10 @@ describe("a placeholder", () => { // expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); // }); // -// it("ignores fields that contain a disabled attribute", () => { +// it("ignores fields that contain a disabled attribute", async () => { // autofillFieldData.disabled = true; // -// // 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 -// autofillOverlayContentService.setupAutofillOverlayListenerOnField( +// await autofillOverlayContentService.setupAutofillOverlayListenerOnField( // autofillFieldElement, // autofillFieldData, // ); @@ -203,12 +199,10 @@ describe("a placeholder", () => { // expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); // }); // -// it("ignores fields that are not viewable", () => { +// it("ignores fields that are not viewable", async () => { // autofillFieldData.viewable = false; // -// // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. -// // eslint-disable-next-line @typescript-eslint/no-floating-promises -// autofillOverlayContentService.setupAutofillOverlayListenerOnField( +// await autofillOverlayContentService.setupAutofillOverlayListenerOnField( // autofillFieldElement, // autofillFieldData, // ); @@ -217,12 +211,10 @@ describe("a placeholder", () => { // }); // // it("ignores fields that are part of the ExcludedOverlayTypes", () => { -// AutoFillConstants.ExcludedOverlayTypes.forEach((excludedType) => { +// AutoFillConstants.ExcludedOverlayTypes.forEach(async (excludedType) => { // autofillFieldData.type = excludedType; // -// // 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 -// autofillOverlayContentService.setupAutofillOverlayListenerOnField( +// await autofillOverlayContentService.setupAutofillOverlayListenerOnField( // autofillFieldElement, // autofillFieldData, // ); @@ -231,12 +223,10 @@ describe("a placeholder", () => { // }); // }); // -// it("ignores fields that contain the keyword `search`", () => { +// it("ignores fields that contain the keyword `search`", async () => { // autofillFieldData.placeholder = "search"; // -// // 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 -// autofillOverlayContentService.setupAutofillOverlayListenerOnField( +// await autofillOverlayContentService.setupAutofillOverlayListenerOnField( // autofillFieldElement, // autofillFieldData, // ); @@ -244,12 +234,10 @@ describe("a placeholder", () => { // expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); // }); // -// it("ignores fields that contain the keyword `captcha` ", () => { +// it("ignores fields that contain the keyword `captcha` ", async () => { // autofillFieldData.placeholder = "captcha"; // -// // 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 -// autofillOverlayContentService.setupAutofillOverlayListenerOnField( +// await autofillOverlayContentService.setupAutofillOverlayListenerOnField( // autofillFieldElement, // autofillFieldData, // ); @@ -257,12 +245,10 @@ describe("a placeholder", () => { // expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); // }); // -// it("ignores fields that do not appear as a login field", () => { +// it("ignores fields that do not appear as a login field", async () => { // autofillFieldData.placeholder = "not-a-login-field"; // -// // 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 -// autofillOverlayContentService.setupAutofillOverlayListenerOnField( +// await autofillOverlayContentService.setupAutofillOverlayListenerOnField( // autofillFieldElement, // autofillFieldData, // ); @@ -271,6 +257,17 @@ describe("a placeholder", () => { // }); // }); // +// it("skips setup on fields that have been previously set up", async () => { +// autofillOverlayContentService["formFieldElements"].add(autofillFieldElement); +// +// await autofillOverlayContentService.setupAutofillOverlayListenerOnField( +// autofillFieldElement, +// autofillFieldData, +// ); +// +// expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); +// }); +// // describe("identifies the overlay visibility setting", () => { // it("defaults the overlay visibility setting to `OnFieldFocus` if a value is not set", async () => { // sendExtensionMessageSpy.mockResolvedValueOnce(undefined); diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 2d96c4d3236..8a63aef5b38 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -63,7 +63,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte formFieldElement: ElementWithOpId, autofillFieldData: AutofillField, ) { - if (this.isIgnoredField(autofillFieldData)) { + if (this.isIgnoredField(autofillFieldData) || this.formFieldElements.has(formFieldElement)) { return; } @@ -620,7 +620,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte private async getBoundingClientRectFromIntersectionObserver( formFieldElement: ElementWithOpId, ): Promise { - if (!("IntersectionObserver" in window) && !("IntersectionObserverEntry" in window)) { + if (!("IntersectionObserver" in globalThis) && !("IntersectionObserverEntry" in globalThis)) { return null; } @@ -749,7 +749,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte if ( this.focusedFieldData.focusedFieldRects?.top > 0 && - this.focusedFieldData.focusedFieldRects?.top < window.innerHeight + window.scrollY + this.focusedFieldData.focusedFieldRects?.top < globalThis.innerHeight + globalThis.scrollY ) { return; } diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 9d94a0c36d0..76141ed0c14 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -42,7 +42,7 @@ import { export default class AutofillService implements AutofillServiceInterface { private openVaultItemPasswordRepromptPopout = openVaultItemPasswordRepromptPopout; - private openPasswordRepromptPopoutDebounce: NodeJS.Timeout; + private openPasswordRepromptPopoutDebounce: number | NodeJS.Timeout; private currentlyOpeningPasswordRepromptPopout = false; private autofillScriptPortsSet = new Set(); static searchFieldNamesSet = new Set(AutoFillConstants.SearchFieldNames); diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index d5c461269b0..79cb41b9a12 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -27,6 +27,7 @@ describe("CollectAutofillContentService", () => { const domElementVisibilityService = new DomElementVisibilityService(); const autofillOverlayContentService = new AutofillOverlayContentService(); let collectAutofillContentService: CollectAutofillContentService; + const mockIntersectionObserver = mock(); beforeEach(() => { document.body.innerHTML = mockLoginForm; @@ -34,6 +35,7 @@ describe("CollectAutofillContentService", () => { domElementVisibilityService, autofillOverlayContentService, ); + window.IntersectionObserver = jest.fn(() => mockIntersectionObserver); }); afterEach(() => { @@ -2527,10 +2529,10 @@ describe("CollectAutofillContentService", () => { }); updatedAttributes.forEach((attribute) => { - it(`will update the ${attribute} value for the field element`, async () => { + it(`will update the ${attribute} value for the field element`, () => { jest.spyOn(collectAutofillContentService["autofillFieldElements"], "set"); - await collectAutofillContentService["updateAutofillFieldElementData"]( + collectAutofillContentService["updateAutofillFieldElementData"]( attribute, fieldElement, autofillField, @@ -2543,10 +2545,10 @@ describe("CollectAutofillContentService", () => { }); }); - it("will not update an attribute value if it is not present in the updateActions object", async () => { + it("will not update an attribute value if it is not present in the updateActions object", () => { jest.spyOn(collectAutofillContentService["autofillFieldElements"], "set"); - await collectAutofillContentService["updateAutofillFieldElementData"]( + collectAutofillContentService["updateAutofillFieldElementData"]( "random-attribute", fieldElement, autofillField, @@ -2555,4 +2557,67 @@ describe("CollectAutofillContentService", () => { expect(collectAutofillContentService["autofillFieldElements"].set).not.toBeCalled(); }); }); + + describe("handleFormElementIntersection", () => { + let isFormFieldViewableSpy: jest.SpyInstance; + let setupAutofillOverlayListenerOnFieldSpy: jest.SpyInstance; + + beforeEach(() => { + isFormFieldViewableSpy = jest.spyOn( + collectAutofillContentService["domElementVisibilityService"], + "isFormFieldViewable", + ); + setupAutofillOverlayListenerOnFieldSpy = jest.spyOn( + collectAutofillContentService["autofillOverlayContentService"], + "setupAutofillOverlayListenerOnField", + ); + }); + + it("skips the initial intersection event for an observed element", async () => { + const formFieldElement = document.createElement("input") as ElementWithOpId; + collectAutofillContentService["elementInitializingIntersectionObserver"].add( + formFieldElement, + ); + const entries = [ + { target: formFieldElement, isIntersecting: true }, + ] as unknown as IntersectionObserverEntry[]; + + await collectAutofillContentService["handleFormElementIntersection"](entries); + + expect(isFormFieldViewableSpy).not.toHaveBeenCalled(); + expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled(); + }); + + it("skips setting up the overlay listeners on a field that is not viewable", async () => { + const formFieldElement = document.createElement("input") as ElementWithOpId; + const entries = [ + { target: formFieldElement, isIntersecting: true }, + ] as unknown as IntersectionObserverEntry[]; + isFormFieldViewableSpy.mockReturnValueOnce(false); + + await collectAutofillContentService["handleFormElementIntersection"](entries); + + expect(isFormFieldViewableSpy).toHaveBeenCalledWith(formFieldElement); + expect(setupAutofillOverlayListenerOnFieldSpy).not.toHaveBeenCalled(); + }); + + it("sets up the overlay listeners on a viewable field", async () => { + const formFieldElement = document.createElement("input") as ElementWithOpId; + const autofillField = mock(); + const entries = [ + { target: formFieldElement, isIntersecting: true }, + ] as unknown as IntersectionObserverEntry[]; + isFormFieldViewableSpy.mockReturnValueOnce(true); + collectAutofillContentService["autofillFieldElements"].set(formFieldElement, autofillField); + collectAutofillContentService["intersectionObserver"] = mockIntersectionObserver; + + await collectAutofillContentService["handleFormElementIntersection"](entries); + + expect(isFormFieldViewableSpy).toHaveBeenCalledWith(formFieldElement); + expect(setupAutofillOverlayListenerOnFieldSpy).toHaveBeenCalledWith( + formFieldElement, + autofillField, + ); + }); + }); }); diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 1de801a2c2c..63dee7f3b19 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -38,8 +38,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte private autofillFormElements: AutofillFormElements = new Map(); private autofillFieldElements: AutofillFieldElements = new Map(); private currentLocationHref = ""; + private intersectionObserver: IntersectionObserver; + private elementInitializingIntersectionObserver: Set = new Set(); private mutationObserver: MutationObserver; - private updateAutofillElementsAfterMutationTimeout: NodeJS.Timeout; + private updateAutofillElementsAfterMutationTimeout: number | NodeJS.Timeout; private readonly updateAfterMutationTimeoutDelay = 1000; private readonly ignoredInputTypes = new Set([ "hidden", @@ -70,6 +72,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte this.setupMutationObserver(); } + if (!this.intersectionObserver) { + this.setupIntersectionObserver(); + } + if (!this.domRecentlyMutated && this.noFieldsFound) { return this.getFormattedPageDetails({}, []); } @@ -180,7 +186,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte ): AutofillPageDetails { return { title: document.title, - url: (document.defaultView || window).location.href, + url: (document.defaultView || globalThis).location.href, documentUrl: document.location.href, forms: autofillFormsData, fields: autofillFieldsData, @@ -240,7 +246,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @private */ private getFormActionAttribute(element: ElementWithOpId): string { - return new URL(this.getPropertyOrAttribute(element, "action"), window.location.href).href; + return new URL(this.getPropertyOrAttribute(element, "action"), globalThis.location.href).href; } /** @@ -360,11 +366,14 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte tagName: this.getAttributeLowerCase(element, "tagName"), }; + if (!autofillFieldBase.viewable) { + this.elementInitializingIntersectionObserver.add(element); + this.intersectionObserver.observe(element); + } + if (elementIsSpanElement(element)) { this.cacheAutofillFieldElement(index, element, autofillFieldBase); - // 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.autofillOverlayContentService?.setupAutofillOverlayListenerOnField( + void this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField( element, autofillFieldBase, ); @@ -407,9 +416,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte }; this.cacheAutofillFieldElement(index, element, autofillField); - // 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.autofillOverlayContentService?.setupAutofillOverlayListenerOnField(element, autofillField); + void this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField( + element, + autofillField, + ); return autofillField; }; @@ -1189,8 +1199,6 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return; } - // 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.updateAutofillFieldElementData( attributeName, targetElement as ElementWithOpId, @@ -1232,13 +1240,12 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte /** * Updates the autofill field element data based on the passed attribute name. + * * @param {string} attributeName * @param {ElementWithOpId} element * @param {AutofillField} dataTarget - * @returns {Promise} - * @private */ - private async updateAutofillFieldElementData( + private updateAutofillFieldElementData( attributeName: string, element: ElementWithOpId, dataTarget: AutofillField, @@ -1304,6 +1311,52 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return attributeValue; } + /** + * Sets up an IntersectionObserver to observe found form + * field elements that are not viewable in the viewport. + */ + private setupIntersectionObserver() { + this.intersectionObserver = new IntersectionObserver(this.handleFormElementIntersection, { + root: null, + rootMargin: "0px", + threshold: 1.0, + }); + } + + /** + * Handles observed form field elements that are not viewable in the viewport. + * Will re-evaluate the visibility of the element and set up the autofill + * overlay listeners on the field if it is viewable. + * + * @param entries - The entries observed by the IntersectionObserver + */ + private handleFormElementIntersection = async (entries: IntersectionObserverEntry[]) => { + for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) { + const entry = entries[entryIndex]; + const formFieldElement = entry.target as ElementWithOpId; + if (this.elementInitializingIntersectionObserver.has(formFieldElement)) { + this.elementInitializingIntersectionObserver.delete(formFieldElement); + continue; + } + + const isViewable = + await this.domElementVisibilityService.isFormFieldViewable(formFieldElement); + if (!isViewable) { + continue; + } + + const cachedAutofillFieldElement = this.autofillFieldElements.get(formFieldElement); + cachedAutofillFieldElement.viewable = true; + + void this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField( + formFieldElement, + cachedAutofillFieldElement, + ); + + this.intersectionObserver.unobserve(entry.target); + } + }; + /** * Destroys the CollectAutofillContentService. Clears all * timeouts and disconnects the mutation observer. @@ -1313,6 +1366,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte clearTimeout(this.updateAutofillElementsAfterMutationTimeout); } this.mutationObserver?.disconnect(); + this.intersectionObserver?.disconnect(); } } diff --git a/apps/browser/src/autofill/services/dom-element-visibility.service.ts b/apps/browser/src/autofill/services/dom-element-visibility.service.ts index acc5b120596..127ce84d919 100644 --- a/apps/browser/src/autofill/services/dom-element-visibility.service.ts +++ b/apps/browser/src/autofill/services/dom-element-visibility.service.ts @@ -66,7 +66,7 @@ class DomElementVisibilityService implements domElementVisibilityServiceInterfac */ private getElementStyle(element: HTMLElement, styleProperty: string): string { if (!this.cachedComputedStyle) { - this.cachedComputedStyle = (element.ownerDocument.defaultView || window).getComputedStyle( + this.cachedComputedStyle = (element.ownerDocument.defaultView || globalThis).getComputedStyle( element, ); } diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index 5ea1284d1bb..5a123bf835f 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -47,7 +47,7 @@ const initEventCount = Object.freeze( ); let confirmSpy: jest.SpyInstance; -let windowSpy: jest.SpyInstance; +let windowLocationSpy: jest.SpyInstance; let savedURLs: string[] | null = ["https://bitwarden.com"]; function setMockWindowLocation({ protocol, @@ -56,11 +56,9 @@ function setMockWindowLocation({ protocol: "http:" | "https:"; hostname: string; }) { - windowSpy.mockImplementation(() => ({ - location: { - protocol, - hostname, - }, + windowLocationSpy.mockImplementation(() => ({ + protocol, + hostname, })); } @@ -76,8 +74,8 @@ describe("InsertAutofillContentService", () => { beforeEach(() => { document.body.innerHTML = mockLoginForm; - confirmSpy = jest.spyOn(window, "confirm"); - windowSpy = jest.spyOn(window, "window", "get"); + confirmSpy = jest.spyOn(globalThis, "confirm"); + windowLocationSpy = jest.spyOn(globalThis, "location", "get"); insertAutofillContentService = new InsertAutofillContentService( domElementVisibilityService, collectAutofillContentService, @@ -101,7 +99,7 @@ describe("InsertAutofillContentService", () => { afterEach(() => { jest.resetAllMocks(); - windowSpy.mockRestore(); + windowLocationSpy.mockRestore(); confirmSpy.mockRestore(); document.body.innerHTML = ""; }); @@ -245,8 +243,8 @@ describe("InsertAutofillContentService", () => { }); it("returns true if the frameElement has a sandbox attribute", () => { - Object.defineProperty(globalThis, "window", { - value: { frameElement: { hasAttribute: jest.fn(() => true) } }, + Object.defineProperty(globalThis, "frameElement", { + value: { hasAttribute: jest.fn(() => true) }, writable: true, }); @@ -991,11 +989,11 @@ describe("InsertAutofillContentService", () => { const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; inputElement.value = "test"; jest.spyOn(inputElement, "focus"); - jest.spyOn(window, "String"); + jest.spyOn(globalThis, "String"); insertAutofillContentService["triggerFocusOnElement"](inputElement, true); - expect(window.String).toHaveBeenCalledWith(value); + expect(globalThis.String).toHaveBeenCalledWith(value); expect(inputElement.focus).toHaveBeenCalled(); expect(inputElement.value).toEqual(value); }); @@ -1005,11 +1003,11 @@ describe("InsertAutofillContentService", () => { const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; inputElement.value = "test"; jest.spyOn(inputElement, "focus"); - jest.spyOn(window, "String"); + jest.spyOn(globalThis, "String"); insertAutofillContentService["triggerFocusOnElement"](inputElement, false); - expect(window.String).not.toHaveBeenCalledWith(); + expect(globalThis.String).not.toHaveBeenCalledWith(); expect(inputElement.focus).toHaveBeenCalled(); expect(inputElement.value).toEqual(value); }); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.ts index dd14cadfa7b..5cfa8091c40 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -65,8 +65,8 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf private fillingWithinSandboxedIframe() { return ( String(self.origin).toLowerCase() === "null" || - window.frameElement?.hasAttribute("sandbox") || - window.location.hostname === "" + globalThis.frameElement?.hasAttribute("sandbox") || + globalThis.location.hostname === "" ); } @@ -79,8 +79,8 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf */ private userCancelledInsecureUrlAutofill(savedUrls?: string[] | null): boolean { if ( - !savedUrls?.some((url) => url.startsWith(`https://${window.location.hostname}`)) || - window.location.protocol !== "http:" || + !savedUrls?.some((url) => url.startsWith(`https://${globalThis.location.hostname}`)) || + globalThis.location.protocol !== "http:" || !this.isPasswordFieldWithinDocument() ) { return false; @@ -88,10 +88,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf const confirmationWarning = [ chrome.i18n.getMessage("insecurePageWarning"), - chrome.i18n.getMessage("insecurePageWarningFillPrompt", [window.location.hostname]), + chrome.i18n.getMessage("insecurePageWarningFillPrompt", [globalThis.location.hostname]), ].join("\n\n"); - return !confirm(confirmationWarning); + return !globalThis.confirm(confirmationWarning); } /** @@ -129,10 +129,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf const confirmationWarning = [ chrome.i18n.getMessage("autofillIframeWarning"), - chrome.i18n.getMessage("autofillIframeWarningTip", [window.location.hostname]), + chrome.i18n.getMessage("autofillIframeWarningTip", [globalThis.location.hostname]), ].join("\n\n"); - return !confirm(confirmationWarning); + return !globalThis.confirm(confirmationWarning); } /** diff --git a/apps/browser/src/background/idle.background.ts b/apps/browser/src/background/idle.background.ts index 28c056cc0e6..7b273459ad9 100644 --- a/apps/browser/src/background/idle.background.ts +++ b/apps/browser/src/background/idle.background.ts @@ -11,7 +11,7 @@ const IdleInterval = 60 * 5; // 5 minutes export default class IdleBackground { private idle: typeof chrome.idle | typeof browser.idle | null; - private idleTimer: number = null; + private idleTimer: number | NodeJS.Timeout = null; private idleState = "active"; constructor( @@ -73,7 +73,7 @@ export default class IdleBackground { private pollIdle(handler: (newState: string) => void) { if (this.idleTimer != null) { - window.clearTimeout(this.idleTimer); + globalThis.clearTimeout(this.idleTimer); this.idleTimer = null; } // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -83,7 +83,7 @@ export default class IdleBackground { this.idleState = state; handler(state); } - this.idleTimer = window.setTimeout(() => this.pollIdle(handler), 5000); + this.idleTimer = globalThis.setTimeout(() => this.pollIdle(handler), 5000); }); } } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 3e84b7544b4..ea43aecff9d 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -9,6 +9,7 @@ import { UserDecryptionOptionsService, AuthRequestServiceAbstraction, AuthRequestService, + LoginEmailServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -70,6 +71,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -93,6 +95,7 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; +import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; @@ -143,6 +146,8 @@ import { } from "@bitwarden/common/tools/password-strength"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -200,10 +205,10 @@ import { BrowserApi } from "../platform/browser/browser-api"; import { flagEnabled } from "../platform/flags"; import { UpdateBadge } from "../platform/listeners/update-badge"; import { BrowserStateService as StateServiceAbstraction } from "../platform/services/abstractions/browser-state.service"; -import { BrowserConfigService } from "../platform/services/browser-config.service"; import { BrowserCryptoService } from "../platform/services/browser-crypto.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import BrowserLocalStorageService from "../platform/services/browser-local-storage.service"; +import BrowserMemoryStorageService from "../platform/services/browser-memory-storage.service"; import BrowserMessagingPrivateModeBackgroundService from "../platform/services/browser-messaging-private-mode-background.service"; import BrowserMessagingService from "../platform/services/browser-messaging.service"; import { BrowserStateService } from "../platform/services/browser-state.service"; @@ -213,7 +218,6 @@ import { BackgroundPlatformUtilsService } from "../platform/services/platform-ut import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; import { BackgroundDerivedStateProvider } from "../platform/state/background-derived-state.provider"; import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service"; -import { BrowserSendService } from "../services/browser-send.service"; import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service"; import FilelessImporterBackground from "../tools/background/fileless-importer.background"; import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.service"; @@ -228,7 +232,7 @@ import RuntimeBackground from "./runtime.background"; export default class MainBackground { messagingService: MessagingServiceAbstraction; - storageService: AbstractStorageService; + storageService: AbstractStorageService & ObservableStorageService; secureStorageService: AbstractStorageService; memoryStorageService: AbstractMemoryStorageService; memoryStorageForStateProviders: AbstractMemoryStorageService & ObservableStorageService; @@ -257,6 +261,7 @@ export default class MainBackground { auditService: AuditServiceAbstraction; authService: AuthServiceAbstraction; loginStrategyService: LoginStrategyServiceAbstraction; + loginEmailService: LoginEmailServiceAbstraction; importApiService: ImportApiServiceAbstraction; importService: ImportServiceAbstraction; exportService: VaultExportServiceAbstraction; @@ -272,6 +277,7 @@ export default class MainBackground { eventUploadService: EventUploadServiceAbstraction; policyService: InternalPolicyServiceAbstraction; sendService: InternalSendServiceAbstraction; + sendStateProvider: SendStateProvider; fileUploadService: FileUploadServiceAbstraction; cipherFileUploadService: CipherFileUploadServiceAbstraction; organizationService: InternalOrganizationServiceAbstraction; @@ -293,7 +299,7 @@ export default class MainBackground { avatarService: AvatarServiceAbstraction; mainContextMenuHandler: MainContextMenuHandler; cipherContextMenuHandler: CipherContextMenuHandler; - configService: BrowserConfigService; + configService: ConfigService; configApiService: ConfigApiServiceAbstraction; devicesApiService: DevicesApiServiceAbstraction; devicesService: DevicesServiceAbstraction; @@ -362,22 +368,28 @@ export default class MainBackground { this.cryptoFunctionService = new WebCryptoFunctionService(self); this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); this.storageService = new BrowserLocalStorageService(); + + const mv3MemoryStorageCreator = (partitionName: string) => { + // TODO: Consider using multithreaded encrypt service in popup only context + return new LocalBackedSessionStorageService( + new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), + this.keyGenerationService, + new BrowserLocalStorageService(), + new BrowserMemoryStorageService(), + partitionName, + ); + }; + this.secureStorageService = this.storageService; // secure storage is not supported in browsers, so we use local storage and warn users when it is used this.memoryStorageService = BrowserApi.isManifestVersion(3) - ? new LocalBackedSessionStorageService( - new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), - this.keyGenerationService, - ) + ? mv3MemoryStorageCreator("stateService") : new MemoryStorageService(); this.memoryStorageForStateProviders = BrowserApi.isManifestVersion(3) - ? new LocalBackedSessionStorageService( - new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), - this.keyGenerationService, - ) + ? mv3MemoryStorageCreator("stateProviders") : new BackgroundMemoryStorageService(); const storageServiceProvider = new StorageServiceProvider( - this.storageService as BrowserLocalStorageService, + this.storageService, this.memoryStorageForStateProviders, ); @@ -443,6 +455,9 @@ export default class MainBackground { this.globalStateProvider, this.platformUtilsService.supportsSecureStorage(), this.secureStorageService, + this.keyGenerationService, + this.encryptService, + this.logService, ); const migrationRunner = new MigrationRunner( @@ -510,7 +525,6 @@ export default class MainBackground { this.badgeSettingsService = new BadgeSettingsService(this.stateProvider); this.policyApiService = new PolicyApiService(this.policyService, this.apiService); this.keyConnectorService = new KeyConnectorService( - this.stateService, this.cryptoService, this.apiService, this.tokenService, @@ -518,6 +532,7 @@ export default class MainBackground { this.organizationService, this.keyGenerationService, logoutCallback, + this.stateProvider, ); this.passwordStrengthService = new PasswordStrengthService(); @@ -550,11 +565,12 @@ export default class MainBackground { this.cryptoFunctionService, this.cryptoService, this.encryptService, - this.stateService, this.appIdService, this.devicesApiService, this.i18nService, this.platformUtilsService, + this.stateProvider, + this.secureStorageService, this.userDecryptionOptionsService, ); @@ -568,14 +584,16 @@ export default class MainBackground { ); this.authService = new AuthService( + this.accountService, backgroundMessagingService, this.cryptoService, this.apiService, this.stateService, + this.tokenService, ); this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService( - this.activeUserStateProvider, + this.stateProvider, ); this.loginStrategyService = new LoginStrategyService( @@ -605,16 +623,13 @@ export default class MainBackground { this.userVerificationApiService = new UserVerificationApiService(this.apiService); - this.configApiService = new ConfigApiService(this.apiService, this.authService); + this.configApiService = new ConfigApiService(this.apiService, this.tokenService); - this.configService = new BrowserConfigService( - this.stateService, + this.configService = new DefaultConfigService( this.configApiService, - this.authService, this.environmentService, this.logService, this.stateProvider, - true, ); this.cipherService = new CipherService( @@ -694,11 +709,14 @@ export default class MainBackground { logoutCallback, ); this.containerService = new ContainerService(this.cryptoService, this.encryptService); - this.sendService = new BrowserSendService( + + this.sendStateProvider = new SendStateProvider(this.stateProvider); + this.sendService = new SendService( this.cryptoService, this.i18nService, this.keyGenerationService, - this.stateService, + this.sendStateProvider, + this.encryptService, ); this.sendApiService = new SendApiService( this.apiService, @@ -989,7 +1007,7 @@ export default class MainBackground { } async bootstrap() { - this.containerService.attachToGlobal(window); + this.containerService.attachToGlobal(self); await this.stateService.init(); @@ -1001,7 +1019,6 @@ export default class MainBackground { this.filelessImporterBackground.init(); await this.commandsBackground.init(); - this.configService.init(); this.twoFactorService.init(); await this.overlayBackground.init(); @@ -1079,7 +1096,9 @@ export default class MainBackground { await this.stateService.setActiveUser(userId); if (userId == null) { - await this.stateService.setRememberedEmail(null); + this.loginEmailService.setRememberEmail(false); + await this.loginEmailService.saveEmailSettings(); + await this.refreshBadge(); await this.refreshMenu(); await this.overlayBackground.updateOverlayCiphers(); @@ -1124,7 +1143,6 @@ export default class MainBackground { this.policyService.clear(userId), this.passwordGenerationService.clear(userId), this.vaultTimeoutSettingsService.clear(userId), - this.keyConnectorService.clear(), this.vaultFilterService.clear(), this.biometricStateService.logout(userId), this.providerService.save(null, userId), diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 0a94e0a79a6..a88bc051d88 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -3,7 +3,7 @@ import { firstValueFrom } from "rxjs"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -46,7 +46,7 @@ export default class RuntimeBackground { private environmentService: BrowserEnvironmentService, private messagingService: MessagingService, private logService: LogService, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private fido2Service: Fido2Service, ) { // onInstalled listener must be wired up before anything else, so we do it in the ctor @@ -89,7 +89,7 @@ export default class RuntimeBackground { BrowserApi.messageListener("runtime.background", backgroundMessageListener); if (this.main.popupOnlyContext) { - (window as any).bitwardenBackgroundMessageListener = backgroundMessageListener; + (self as any).bitwardenBackgroundMessageListener = backgroundMessageListener; } } @@ -136,7 +136,7 @@ export default class RuntimeBackground { await this.main.refreshBadge(); await this.main.refreshMenu(); }, 2000); - this.configService.triggerServerConfigFetch(); + await this.configService.ensureConfigFetched(); } break; case "openPopup": diff --git a/apps/browser/src/background/service-factories/send-service.factory.ts b/apps/browser/src/background/service-factories/send-service.factory.ts index bca46b47030..942861b9260 100644 --- a/apps/browser/src/background/service-factories/send-service.factory.ts +++ b/apps/browser/src/background/service-factories/send-service.factory.ts @@ -1,9 +1,14 @@ +import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { CryptoServiceInitOptions, cryptoServiceFactory, } from "../../platform/background/service-factories/crypto-service.factory"; +import { + EncryptServiceInitOptions, + encryptServiceFactory, +} from "../../platform/background/service-factories/encrypt-service.factory"; import { FactoryOptions, CachedServices, @@ -17,11 +22,11 @@ import { KeyGenerationServiceInitOptions, keyGenerationServiceFactory, } from "../../platform/background/service-factories/key-generation-service.factory"; + import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../platform/background/service-factories/state-service.factory"; -import { BrowserSendService } from "../../services/browser-send.service"; + SendStateProviderInitOptions, + sendStateProviderFactory, +} from "./send-state-provider.factory"; type SendServiceFactoryOptions = FactoryOptions; @@ -29,7 +34,8 @@ export type SendServiceInitOptions = SendServiceFactoryOptions & CryptoServiceInitOptions & I18nServiceInitOptions & KeyGenerationServiceInitOptions & - StateServiceInitOptions; + SendStateProviderInitOptions & + EncryptServiceInitOptions; export function sendServiceFactory( cache: { sendService?: InternalSendService } & CachedServices, @@ -40,11 +46,12 @@ export function sendServiceFactory( "sendService", opts, async () => - new BrowserSendService( + new SendService( await cryptoServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), await keyGenerationServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), + await sendStateProviderFactory(cache, opts), + await encryptServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/background/service-factories/send-state-provider.factory.ts b/apps/browser/src/background/service-factories/send-state-provider.factory.ts new file mode 100644 index 00000000000..01319756e47 --- /dev/null +++ b/apps/browser/src/background/service-factories/send-state-provider.factory.ts @@ -0,0 +1,28 @@ +import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; + +import { + CachedServices, + FactoryOptions, + factory, +} from "../../platform/background/service-factories/factory-options"; +import { + StateProviderInitOptions, + stateProviderFactory, +} from "../../platform/background/service-factories/state-provider.factory"; + +type SendStateProviderFactoryOptions = FactoryOptions; + +export type SendStateProviderInitOptions = SendStateProviderFactoryOptions & + StateProviderInitOptions; + +export function sendStateProviderFactory( + cache: { sendStateProvider?: SendStateProvider } & CachedServices, + opts: SendStateProviderInitOptions, +): Promise { + return factory( + cache, + "sendStateProvider", + opts, + async () => new SendStateProvider(await stateProviderFactory(cache, opts)), + ); +} diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 2ffa0617576..d1979b4e23a 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.3.0", + "version": "2024.3.1", "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 86510347d4d..e7b0c0cd1e7 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.3.0", + "version": "2024.3.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/platform/background.ts b/apps/browser/src/platform/background.ts index b71b4d96b01..9c3510178cd 100644 --- a/apps/browser/src/platform/background.ts +++ b/apps/browser/src/platform/background.ts @@ -1,42 +1,35 @@ +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; + import MainBackground from "../background/main.background"; -import { onAlarmListener } from "./alarms/on-alarm-listener"; -import { registerAlarms } from "./alarms/register-alarms"; import { BrowserApi } from "./browser/browser-api"; -import { - contextMenusClickedListener, - onCommandListener, - onInstallListener, - runtimeMessageListener, - windowsOnFocusChangedListener, - tabsOnActivatedListener, - tabsOnReplacedListener, - tabsOnUpdatedListener, -} from "./listeners"; -if (BrowserApi.isManifestVersion(3)) { - chrome.commands.onCommand.addListener(onCommandListener); - chrome.runtime.onInstalled.addListener(onInstallListener); - chrome.alarms.onAlarm.addListener(onAlarmListener); - registerAlarms(); - chrome.windows.onFocusChanged.addListener(windowsOnFocusChangedListener); - chrome.tabs.onActivated.addListener(tabsOnActivatedListener); - chrome.tabs.onReplaced.addListener(tabsOnReplacedListener); - chrome.tabs.onUpdated.addListener(tabsOnUpdatedListener); - chrome.contextMenus.onClicked.addListener(contextMenusClickedListener); - BrowserApi.messageListener( - "runtime.background", - (message: { command: string }, sender, sendResponse) => { - // 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 - runtimeMessageListener(message, sender); - }, - ); -} else { - const bitwardenMain = ((window as any).bitwardenMain = new MainBackground()); - // 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 - bitwardenMain.bootstrap().then(() => { +const logService = new ConsoleLogService(false); +const bitwardenMain = ((self as any).bitwardenMain = new MainBackground()); +bitwardenMain + .bootstrap() + .then(() => { // Finished bootstrapping - }); + if (BrowserApi.isManifestVersion(3)) { + startHeartbeat().catch((error) => logService.error(error)); + } + }) + .catch((error) => logService.error(error)); + +/** + * Tracks when a service worker was last alive and extends the service worker + * lifetime by writing the current time to extension storage every 20 seconds. + */ +async function runHeartbeat() { + await chrome.storage.local.set({ "last-heartbeat": new Date().getTime() }); +} + +/** + * Starts the heartbeat interval which keeps the service worker alive. + */ +async function startHeartbeat() { + // Run the heartbeat once at service worker startup, then again every 20 seconds. + runHeartbeat() + .then(() => setInterval(runHeartbeat, 20 * 1000)) + .catch((error) => logService.error(error)); } diff --git a/apps/browser/src/platform/background/service-factories/billing-account-profile-state-service.factory.ts b/apps/browser/src/platform/background/service-factories/billing-account-profile-state-service.factory.ts index 80482eacb67..378707d6be3 100644 --- a/apps/browser/src/platform/background/service-factories/billing-account-profile-state-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/billing-account-profile-state-service.factory.ts @@ -1,9 +1,8 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; -import { activeUserStateProviderFactory } from "./active-user-state-provider.factory"; import { FactoryOptions, CachedServices, factory } from "./factory-options"; -import { StateProviderInitOptions } from "./state-provider.factory"; +import { StateProviderInitOptions, stateProviderFactory } from "./state-provider.factory"; type BillingAccountProfileStateServiceFactoryOptions = FactoryOptions; @@ -21,8 +20,6 @@ export function billingAccountProfileStateServiceFactory( "billingAccountProfileStateService", opts, async () => - new DefaultBillingAccountProfileStateService( - await activeUserStateProviderFactory(cache, opts), - ), + new DefaultBillingAccountProfileStateService(await stateProviderFactory(cache, opts)), ); } diff --git a/apps/browser/src/platform/background/service-factories/config-api.service.factory.ts b/apps/browser/src/platform/background/service-factories/config-api.service.factory.ts index c0dbf1f475d..3d7d508832b 100644 --- a/apps/browser/src/platform/background/service-factories/config-api.service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/config-api.service.factory.ts @@ -2,9 +2,9 @@ import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstract import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { - authServiceFactory, - AuthServiceInitOptions, -} from "../../../auth/background/service-factories/auth-service.factory"; + tokenServiceFactory, + TokenServiceInitOptions, +} from "../../../auth/background/service-factories/token-service.factory"; import { apiServiceFactory, ApiServiceInitOptions } from "./api-service.factory"; import { FactoryOptions, CachedServices, factory } from "./factory-options"; @@ -13,7 +13,7 @@ type ConfigApiServiceFactoyOptions = FactoryOptions; export type ConfigApiServiceInitOptions = ConfigApiServiceFactoyOptions & ApiServiceInitOptions & - AuthServiceInitOptions; + TokenServiceInitOptions; export function configApiServiceFactory( cache: { configApiService?: ConfigApiServiceAbstraction } & CachedServices, @@ -26,7 +26,7 @@ export function configApiServiceFactory( async () => new ConfigApiService( await apiServiceFactory(cache, opts), - await authServiceFactory(cache, opts), + await tokenServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/platform/background/service-factories/config-service.factory.ts b/apps/browser/src/platform/background/service-factories/config-service.factory.ts index 4e31fb3141a..a899f8fd9af 100644 --- a/apps/browser/src/platform/background/service-factories/config-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/config-service.factory.ts @@ -1,10 +1,5 @@ -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; -import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; - -import { - authServiceFactory, - AuthServiceInitOptions, -} from "../../../auth/background/service-factories/auth-service.factory"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; import { configApiServiceFactory, ConfigApiServiceInitOptions } from "./config-api.service.factory"; import { @@ -13,39 +8,30 @@ import { } from "./environment-service.factory"; import { FactoryOptions, CachedServices, factory } from "./factory-options"; import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; -import { stateProviderFactory } from "./state-provider.factory"; -import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; +import { stateProviderFactory, StateProviderInitOptions } from "./state-provider.factory"; -type ConfigServiceFactoryOptions = FactoryOptions & { - configServiceOptions?: { - subscribe?: boolean; - }; -}; +type ConfigServiceFactoryOptions = FactoryOptions; export type ConfigServiceInitOptions = ConfigServiceFactoryOptions & - StateServiceInitOptions & ConfigApiServiceInitOptions & - AuthServiceInitOptions & EnvironmentServiceInitOptions & - LogServiceInitOptions; + LogServiceInitOptions & + StateProviderInitOptions; export function configServiceFactory( - cache: { configService?: ConfigServiceAbstraction } & CachedServices, + cache: { configService?: ConfigService } & CachedServices, opts: ConfigServiceInitOptions, -): Promise { +): Promise { return factory( cache, "configService", opts, async () => - new ConfigService( - await stateServiceFactory(cache, opts), + new DefaultConfigService( await configApiServiceFactory(cache, opts), - await authServiceFactory(cache, opts), await environmentServiceFactory(cache, opts), await logServiceFactory(cache, opts), await stateProviderFactory(cache, opts), - opts.configServiceOptions?.subscribe ?? true, ), ); } diff --git a/apps/browser/src/platform/background/service-factories/storage-service.factory.ts b/apps/browser/src/platform/background/service-factories/storage-service.factory.ts index 6a854255f5e..19d5a9c1403 100644 --- a/apps/browser/src/platform/background/service-factories/storage-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/storage-service.factory.ts @@ -7,6 +7,7 @@ import { MemoryStorageService } from "@bitwarden/common/platform/services/memory import { BrowserApi } from "../../browser/browser-api"; import BrowserLocalStorageService from "../../services/browser-local-storage.service"; +import BrowserMemoryStorageService from "../../services/browser-memory-storage.service"; import { LocalBackedSessionStorageService } from "../../services/local-backed-session-storage.service"; import { BackgroundMemoryStorageService } from "../../storage/background-memory-storage.service"; @@ -17,13 +18,14 @@ import { keyGenerationServiceFactory, } from "./key-generation-service.factory"; -type StorageServiceFactoryOptions = FactoryOptions; - -export type DiskStorageServiceInitOptions = StorageServiceFactoryOptions; -export type SecureStorageServiceInitOptions = StorageServiceFactoryOptions; -export type MemoryStorageServiceInitOptions = StorageServiceFactoryOptions & +export type DiskStorageServiceInitOptions = FactoryOptions; +export type SecureStorageServiceInitOptions = FactoryOptions; +export type SessionStorageServiceInitOptions = FactoryOptions; +export type MemoryStorageServiceInitOptions = FactoryOptions & EncryptServiceInitOptions & - KeyGenerationServiceInitOptions; + KeyGenerationServiceInitOptions & + DiskStorageServiceInitOptions & + SessionStorageServiceInitOptions; export function diskStorageServiceFactory( cache: { diskStorageService?: AbstractStorageService } & CachedServices, @@ -47,6 +49,13 @@ export function secureStorageServiceFactory( return factory(cache, "secureStorageService", opts, () => new BrowserLocalStorageService()); } +export function sessionStorageServiceFactory( + cache: { sessionStorageService?: AbstractStorageService } & CachedServices, + opts: SessionStorageServiceInitOptions, +): Promise { + return factory(cache, "sessionStorageService", opts, () => new BrowserMemoryStorageService()); +} + export function memoryStorageServiceFactory( cache: { memoryStorageService?: AbstractMemoryStorageService } & CachedServices, opts: MemoryStorageServiceInitOptions, @@ -56,6 +65,9 @@ export function memoryStorageServiceFactory( return new LocalBackedSessionStorageService( await encryptServiceFactory(cache, opts), await keyGenerationServiceFactory(cache, opts), + await diskStorageServiceFactory(cache, opts), + await sessionStorageServiceFactory(cache, opts), + "serviceFactories", ); } return new MemoryStorageService(); diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index e93a4573a5c..4cba5a5f275 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -357,11 +357,11 @@ export class BrowserApi { private static setupUnloadListeners() { // The MDN recommend using 'visibilitychange' but that event is fired any time the popup window is obscured as well // 'pagehide' works just like 'unload' but is compatible with the back/forward cache, so we prefer using that one - window.onpagehide = () => { + self.addEventListener("pagehide", () => { for (const [event, callback] of BrowserApi.trackedChromeEventListeners) { event.removeListener(callback); } - }; + }); } static sendMessage(subscriber: string, arg: any = {}) { @@ -429,7 +429,7 @@ export class BrowserApi { return; } - const currentHref = window.location.href; + const currentHref = self.location.href; views .filter((w) => w.location.href != null && !w.location.href.includes("background.html")) .filter((w) => !exemptCurrentHref || w.location.href !== currentHref) diff --git a/apps/browser/src/platform/offscreen-document/offscreen-document.ts b/apps/browser/src/platform/offscreen-document/offscreen-document.ts index 02ae5cdb236..627036b80bd 100644 --- a/apps/browser/src/platform/offscreen-document/offscreen-document.ts +++ b/apps/browser/src/platform/offscreen-document/offscreen-document.ts @@ -29,14 +29,14 @@ class OffscreenDocument implements OffscreenDocumentInterface { * @param message - The extension message containing the text to copy */ private async handleOffscreenCopyToClipboard(message: OffscreenDocumentExtensionMessage) { - await BrowserClipboardService.copy(window, message.text); + await BrowserClipboardService.copy(self, message.text); } /** * Reads the user's clipboard and returns the text. */ private async handleOffscreenReadFromClipboard() { - return await BrowserClipboardService.read(window); + return await BrowserClipboardService.read(self); } /** diff --git a/apps/browser/src/platform/services/browser-file-download.service.ts b/apps/browser/src/platform/popup/services/browser-file-download.service.ts similarity index 93% rename from apps/browser/src/platform/services/browser-file-download.service.ts rename to apps/browser/src/platform/popup/services/browser-file-download.service.ts index 8cb4d498a32..e9aaa639c42 100644 --- a/apps/browser/src/platform/services/browser-file-download.service.ts +++ b/apps/browser/src/platform/popup/services/browser-file-download.service.ts @@ -5,8 +5,8 @@ import { FileDownloadRequest } from "@bitwarden/common/platform/abstractions/fil import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SafariApp } from "../../browser/safariApp"; -import { BrowserApi } from "../browser/browser-api"; +import { SafariApp } from "../../../browser/safariApp"; +import { BrowserApi } from "../../browser/browser-api"; @Injectable() export class BrowserFileDownloadService implements FileDownloadService { diff --git a/apps/browser/src/platform/services/abstractions/browser-state.service.ts b/apps/browser/src/platform/services/abstractions/browser-state.service.ts index 88c2312762b..82ec54975ae 100644 --- a/apps/browser/src/platform/services/abstractions/browser-state.service.ts +++ b/apps/browser/src/platform/services/abstractions/browser-state.service.ts @@ -3,22 +3,9 @@ import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage import { Account } from "../../../models/account"; import { BrowserComponentState } from "../../../models/browserComponentState"; -import { BrowserGroupingsComponentState } from "../../../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; export abstract class BrowserStateService extends BaseStateServiceAbstraction { - getBrowserGroupingComponentState: ( - options?: StorageOptions, - ) => Promise; - setBrowserGroupingComponentState: ( - value: BrowserGroupingsComponentState, - options?: StorageOptions, - ) => Promise; - getBrowserVaultItemsComponentState: (options?: StorageOptions) => Promise; - setBrowserVaultItemsComponentState: ( - value: BrowserComponentState, - options?: StorageOptions, - ) => Promise; getBrowserSendComponentState: (options?: StorageOptions) => Promise; setBrowserSendComponentState: ( value: BrowserSendComponentState, diff --git a/apps/browser/src/platform/services/browser-config.service.ts b/apps/browser/src/platform/services/browser-config.service.ts deleted file mode 100644 index be8d087f3b6..00000000000 --- a/apps/browser/src/platform/services/browser-config.service.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ReplaySubject } from "rxjs"; - -import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; -import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; -import { StateProvider } from "@bitwarden/common/platform/state"; - -import { browserSession, sessionSync } from "../decorators/session-sync-observable"; - -@browserSession -export class BrowserConfigService extends ConfigService { - @sessionSync({ initializer: ServerConfig.fromJSON }) - protected _serverConfig: ReplaySubject; - - constructor( - stateService: StateService, - configApiService: ConfigApiServiceAbstraction, - authService: AuthService, - environmentService: EnvironmentService, - logService: LogService, - stateProvider: StateProvider, - subscribe = false, - ) { - super( - stateService, - configApiService, - authService, - environmentService, - logService, - stateProvider, - subscribe, - ); - } -} diff --git a/apps/browser/src/platform/services/browser-messaging-private-mode-background.service.ts b/apps/browser/src/platform/services/browser-messaging-private-mode-background.service.ts index c2a6f8c5e1f..0c7008473bb 100644 --- a/apps/browser/src/platform/services/browser-messaging-private-mode-background.service.ts +++ b/apps/browser/src/platform/services/browser-messaging-private-mode-background.service.ts @@ -3,6 +3,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag export default class BrowserMessagingPrivateModeBackgroundService implements MessagingService { send(subscriber: string, arg: any = {}) { const message = Object.assign({}, { command: subscriber }, arg); - (window as any).bitwardenPopupMainMessageListener(message); + (self as any).bitwardenPopupMainMessageListener(message); } } diff --git a/apps/browser/src/platform/services/browser-messaging-private-mode-popup.service.ts b/apps/browser/src/platform/services/browser-messaging-private-mode-popup.service.ts index 5572ba1ba41..5883f611970 100644 --- a/apps/browser/src/platform/services/browser-messaging-private-mode-popup.service.ts +++ b/apps/browser/src/platform/services/browser-messaging-private-mode-popup.service.ts @@ -3,6 +3,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag export default class BrowserMessagingPrivateModePopupService implements MessagingService { send(subscriber: string, arg: any = {}) { const message = Object.assign({}, { command: subscriber }, arg); - (window as any).bitwardenBackgroundMessageListener(message); + (self as any).bitwardenBackgroundMessageListener(message); } } diff --git a/apps/browser/src/platform/services/browser-state.service.spec.ts b/apps/browser/src/platform/services/browser-state.service.spec.ts index 3069b8f1749..7e75b9b7077 100644 --- a/apps/browser/src/platform/services/browser-state.service.spec.ts +++ b/apps/browser/src/platform/services/browser-state.service.spec.ts @@ -18,7 +18,6 @@ import { UserId } from "@bitwarden/common/types/guid"; import { Account } from "../../models/account"; import { BrowserComponentState } from "../../models/browserComponentState"; -import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../../models/browserSendComponentState"; import { BrowserStateService } from "./browser-state.service"; @@ -86,27 +85,6 @@ describe("Browser State Service", () => { ); }); - describe("getBrowserGroupingComponentState", () => { - it("should return a BrowserGroupingsComponentState", async () => { - state.accounts[userId].groupings = new BrowserGroupingsComponentState(); - - const actual = await sut.getBrowserGroupingComponentState(); - expect(actual).toBeInstanceOf(BrowserGroupingsComponentState); - }); - }); - - describe("getBrowserVaultItemsComponentState", () => { - it("should return a BrowserComponentState", async () => { - const componentState = new BrowserComponentState(); - componentState.scrollY = 0; - componentState.searchText = "test"; - state.accounts[userId].ciphers = componentState; - - const actual = await sut.getBrowserVaultItemsComponentState(); - expect(actual).toStrictEqual(componentState); - }); - }); - describe("getBrowserSendComponentState", () => { it("should return a BrowserSendComponentState", async () => { const sendState = new BrowserSendComponentState(); diff --git a/apps/browser/src/platform/services/browser-state.service.ts b/apps/browser/src/platform/services/browser-state.service.ts index f7ee74be217..ea410ee83ab 100644 --- a/apps/browser/src/platform/services/browser-state.service.ts +++ b/apps/browser/src/platform/services/browser-state.service.ts @@ -16,7 +16,6 @@ import { StateService as BaseStateService } from "@bitwarden/common/platform/ser import { Account } from "../../models/account"; import { BrowserComponentState } from "../../models/browserComponentState"; -import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../../models/browserSendComponentState"; import { BrowserApi } from "../browser/browser-api"; import { browserSession, sessionSync } from "../decorators/session-sync-observable"; @@ -116,50 +115,6 @@ export class BrowserStateService ); } - async getBrowserGroupingComponentState( - options?: StorageOptions, - ): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.groupings; - } - - async setBrowserGroupingComponentState( - value: BrowserGroupingsComponentState, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.groupings = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - - async getBrowserVaultItemsComponentState( - options?: StorageOptions, - ): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.ciphers; - } - - async setBrowserVaultItemsComponentState( - value: BrowserComponentState, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.ciphers = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - async getBrowserSendComponentState(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) diff --git a/apps/browser/src/platform/services/i18n.service.ts b/apps/browser/src/platform/services/i18n.service.ts index 334ad8dc6cf..a27c3935d75 100644 --- a/apps/browser/src/platform/services/i18n.service.ts +++ b/apps/browser/src/platform/services/i18n.service.ts @@ -25,6 +25,7 @@ export default class I18nService extends BaseI18nService { "bs", "ca", "cs", + "cy", "da", "de", "el", @@ -37,6 +38,7 @@ export default class I18nService extends BaseI18nService { "fi", "fil", "fr", + "gl", "he", "hi", "hr", @@ -51,9 +53,13 @@ export default class I18nService extends BaseI18nService { "lt", "lv", "ml", + "mr", + "my", "nb", + "ne", "nl", "nn", + "or", "pl", "pt-BR", "pt-PT", @@ -64,6 +70,7 @@ export default class I18nService extends BaseI18nService { "sl", "sr", "sv", + "te", "th", "tr", "uk", diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts index fff9f2c28f3..7740a22071d 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts @@ -2,45 +2,70 @@ import { mock, MockProxy } from "jest-mock-extended"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; +import { + AbstractMemoryStorageService, + AbstractStorageService, + StorageUpdate, +} from "@bitwarden/common/platform/abstractions/storage.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import BrowserLocalStorageService from "./browser-local-storage.service"; -import BrowserMemoryStorageService from "./browser-memory-storage.service"; import { LocalBackedSessionStorageService } from "./local-backed-session-storage.service"; -describe("Browser Session Storage Service", () => { +describe("LocalBackedSessionStorage", () => { let encryptService: MockProxy; let keyGenerationService: MockProxy; + let localStorageService: MockProxy; + let sessionStorageService: MockProxy; let cache: Map; const testObj = { a: 1, b: 2 }; - let localStorage: BrowserLocalStorageService; - let sessionStorage: BrowserMemoryStorageService; - const key = new SymmetricCryptoKey(Utils.fromUtf8ToArray("00000000000000000000000000000000")); let getSessionKeySpy: jest.SpyInstance; + let sendUpdateSpy: jest.SpyInstance; const mockEnc = (input: string) => Promise.resolve(new EncString("ENCRYPTED" + input)); let sut: LocalBackedSessionStorageService; + const mockExistingSessionKey = (key: SymmetricCryptoKey) => { + sessionStorageService.get.mockImplementation((storageKey) => { + if (storageKey === "localEncryptionKey_test") { + return Promise.resolve(key?.toJSON()); + } + + return Promise.reject("No implementation for " + storageKey); + }); + }; + beforeEach(() => { encryptService = mock(); keyGenerationService = mock(); + localStorageService = mock(); + sessionStorageService = mock(); - sut = new LocalBackedSessionStorageService(encryptService, keyGenerationService); + sut = new LocalBackedSessionStorageService( + encryptService, + keyGenerationService, + localStorageService, + sessionStorageService, + "test", + ); cache = sut["cache"]; - localStorage = sut["localStorage"]; - sessionStorage = sut["sessionStorage"]; + + keyGenerationService.createKeyWithPurpose.mockResolvedValue({ + derivedKey: key, + salt: "bitwarden-ephemeral", + material: null, // Not used + }); + getSessionKeySpy = jest.spyOn(sut, "getSessionEncKey"); getSessionKeySpy.mockResolvedValue(key); - }); - it("should exist", () => { - expect(sut).toBeInstanceOf(LocalBackedSessionStorageService); + sendUpdateSpy = jest.spyOn(sut, "sendUpdate"); + sendUpdateSpy.mockReturnValue(); }); describe("get", () => { @@ -54,7 +79,7 @@ describe("Browser Session Storage Service", () => { const session = { test: testObj }; beforeEach(() => { - jest.spyOn(sut, "getSessionEncKey").mockResolvedValue(key); + mockExistingSessionKey(key); }); describe("no session retrieved", () => { @@ -62,6 +87,7 @@ describe("Browser Session Storage Service", () => { let spy: jest.SpyInstance; beforeEach(async () => { spy = jest.spyOn(sut, "getLocalSession").mockResolvedValue(null); + localStorageService.get.mockResolvedValue(null); result = await sut.get("test"); }); @@ -123,31 +149,31 @@ describe("Browser Session Storage Service", () => { describe("remove", () => { it("should save null", async () => { - const spy = jest.spyOn(sut, "save"); - spy.mockResolvedValue(null); await sut.remove("test"); - expect(spy).toHaveBeenCalledWith("test", null); + expect(sendUpdateSpy).toHaveBeenCalledWith({ key: "test", updateType: "remove" }); }); }); describe("save", () => { describe("caching", () => { beforeEach(() => { - jest.spyOn(localStorage, "get").mockResolvedValue(null); - jest.spyOn(sessionStorage, "get").mockResolvedValue(null); - jest.spyOn(localStorage, "save").mockResolvedValue(); - jest.spyOn(sessionStorage, "save").mockResolvedValue(); + localStorageService.get.mockResolvedValue(null); + sessionStorageService.get.mockResolvedValue(null); + + localStorageService.save.mockResolvedValue(); + sessionStorageService.save.mockResolvedValue(); encryptService.encrypt.mockResolvedValue(mockEnc("{}")); }); it("should remove key from cache if value is null", async () => { cache.set("test", {}); - const deleteSpy = jest.spyOn(cache, "delete"); + const cacheSetSpy = jest.spyOn(cache, "set"); expect(cache.has("test")).toBe(true); await sut.save("test", null); - expect(cache.has("test")).toBe(false); - expect(deleteSpy).toHaveBeenCalledWith("test"); + // Don't remove from cache, just replace with null + expect(cache.get("test")).toBe(null); + expect(cacheSetSpy).toHaveBeenCalledWith("test", null); }); it("should set cache if value is non-null", async () => { @@ -197,7 +223,7 @@ describe("Browser Session Storage Service", () => { }); it("should return the stored symmetric crypto key", async () => { - jest.spyOn(sessionStorage, "get").mockResolvedValue({ ...key }); + sessionStorageService.get.mockResolvedValue({ ...key }); const result = await sut.getSessionEncKey(); expect(result).toStrictEqual(key); @@ -205,7 +231,6 @@ describe("Browser Session Storage Service", () => { describe("new key creation", () => { beforeEach(() => { - jest.spyOn(sessionStorage, "get").mockResolvedValue(null); keyGenerationService.createKeyWithPurpose.mockResolvedValue({ salt: "salt", material: null, @@ -218,25 +243,24 @@ describe("Browser Session Storage Service", () => { const result = await sut.getSessionEncKey(); expect(result).toStrictEqual(key); - expect(keyGenerationService.createKeyWithPurpose).toBeCalledTimes(1); + expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalledTimes(1); }); it("should store a symmetric crypto key if it makes one", async () => { const spy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); await sut.getSessionEncKey(); - expect(spy).toBeCalledWith(key); + expect(spy).toHaveBeenCalledWith(key); }); }); }); describe("getLocalSession", () => { it("should return null if session is null", async () => { - const spy = jest.spyOn(localStorage, "get").mockResolvedValue(null); const result = await sut.getLocalSession(key); expect(result).toBeNull(); - expect(spy).toBeCalledWith("session"); + expect(localStorageService.get).toHaveBeenCalledWith("session_test"); }); describe("non-null sessions", () => { @@ -245,7 +269,7 @@ describe("Browser Session Storage Service", () => { const decryptedSession = JSON.stringify(session); beforeEach(() => { - jest.spyOn(localStorage, "get").mockResolvedValue(encSession.encryptedString); + localStorageService.get.mockResolvedValue(encSession.encryptedString); }); it("should decrypt returned sessions", async () => { @@ -267,13 +291,12 @@ describe("Browser Session Storage Service", () => { it("should remove state if decryption fails", async () => { encryptService.decryptToUtf8.mockResolvedValue(null); const setSessionEncKeySpy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); - const removeLocalSessionSpy = jest.spyOn(localStorage, "remove").mockResolvedValue(); const result = await sut.getLocalSession(key); expect(result).toBeNull(); expect(setSessionEncKeySpy).toHaveBeenCalledWith(null); - expect(removeLocalSessionSpy).toHaveBeenCalledWith("session"); + expect(localStorageService.remove).toHaveBeenCalledWith("session_test"); }); }); }); @@ -284,7 +307,7 @@ describe("Browser Session Storage Service", () => { it("should encrypt a stringified session", async () => { encryptService.encrypt.mockImplementation(mockEnc); - jest.spyOn(localStorage, "save").mockResolvedValue(); + localStorageService.save.mockResolvedValue(); await sut.setLocalSession(testSession, key); expect(encryptService.encrypt).toHaveBeenNthCalledWith(1, testJSON, key); @@ -292,32 +315,31 @@ describe("Browser Session Storage Service", () => { it("should remove local session if null", async () => { encryptService.encrypt.mockResolvedValue(null); - const spy = jest.spyOn(localStorage, "remove").mockResolvedValue(); await sut.setLocalSession(null, key); - expect(spy).toHaveBeenCalledWith("session"); + expect(localStorageService.remove).toHaveBeenCalledWith("session_test"); }); it("should save encrypted string", async () => { encryptService.encrypt.mockImplementation(mockEnc); - const spy = jest.spyOn(localStorage, "save").mockResolvedValue(); await sut.setLocalSession(testSession, key); - expect(spy).toHaveBeenCalledWith("session", (await mockEnc(testJSON)).encryptedString); + expect(localStorageService.save).toHaveBeenCalledWith( + "session_test", + (await mockEnc(testJSON)).encryptedString, + ); }); }); describe("setSessionKey", () => { it("should remove if null", async () => { - const spy = jest.spyOn(sessionStorage, "remove").mockResolvedValue(); await sut.setSessionEncKey(null); - expect(spy).toHaveBeenCalledWith("localEncryptionKey"); + expect(sessionStorageService.remove).toHaveBeenCalledWith("localEncryptionKey_test"); }); it("should save key when not null", async () => { - const spy = jest.spyOn(sessionStorage, "save").mockResolvedValue(); await sut.setSessionEncKey(key); - expect(spy).toHaveBeenCalledWith("localEncryptionKey", key); + expect(sessionStorageService.save).toHaveBeenCalledWith("localEncryptionKey_test", key); }); }); }); diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.ts index b2823ffe4b4..3f01e4169e9 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.ts @@ -1,40 +1,60 @@ -import { Subject } from "rxjs"; +import { Observable, Subject, filter, map, merge, share, tap } from "rxjs"; import { Jsonify } from "type-fest"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { AbstractMemoryStorageService, + AbstractStorageService, + ObservableStorageService, StorageUpdate, } from "@bitwarden/common/platform/abstractions/storage.service"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { MemoryStorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { fromChromeEvent } from "../browser/from-chrome-event"; import { devFlag } from "../decorators/dev-flag.decorator"; import { devFlagEnabled } from "../flags"; -import BrowserLocalStorageService from "./browser-local-storage.service"; -import BrowserMemoryStorageService from "./browser-memory-storage.service"; - -const keys = { - encKey: "localEncryptionKey", - sessionKey: "session", -}; - -export class LocalBackedSessionStorageService extends AbstractMemoryStorageService { +export class LocalBackedSessionStorageService + extends AbstractMemoryStorageService + implements ObservableStorageService +{ private cache = new Map(); - private localStorage = new BrowserLocalStorageService(); - private sessionStorage = new BrowserMemoryStorageService(); private updatesSubject = new Subject(); - updates$; + + private commandName = `localBackedSessionStorage_${this.name}`; + private encKey = `localEncryptionKey_${this.name}`; + private sessionKey = `session_${this.name}`; + + updates$: Observable; constructor( private encryptService: EncryptService, private keyGenerationService: KeyGenerationService, + private localStorage: AbstractStorageService, + private sessionStorage: AbstractStorageService, + private name: string, ) { super(); - this.updates$ = this.updatesSubject.asObservable(); + + const remoteObservable = fromChromeEvent(chrome.runtime.onMessage).pipe( + filter(([msg]) => msg.command === this.commandName), + map(([msg]) => msg.update as StorageUpdate), + tap((update) => { + if (update.updateType === "remove") { + this.cache.set(update.key, null); + } else { + this.cache.delete(update.key); + } + }), + share(), + ); + + remoteObservable.subscribe(); + + this.updates$ = merge(this.updatesSubject.asObservable(), remoteObservable); } get valuesRequireDeserialization(): boolean { @@ -70,23 +90,37 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi async save(key: string, obj: T): Promise { if (obj == null) { - this.cache.delete(key); - } else { - this.cache.set(key, obj); + return await this.remove(key); } + this.cache.set(key, obj); + await this.updateLocalSessionValue(key, obj); + this.sendUpdate({ key, updateType: "save" }); + } + + async remove(key: string): Promise { + this.cache.set(key, null); + await this.updateLocalSessionValue(key, null); + this.sendUpdate({ key, updateType: "remove" }); + } + + sendUpdate(storageUpdate: StorageUpdate) { + this.updatesSubject.next(storageUpdate); + void chrome.runtime.sendMessage({ + command: this.commandName, + update: storageUpdate, + }); + } + + private async updateLocalSessionValue(key: string, obj: T) { const sessionEncKey = await this.getSessionEncKey(); const localSession = (await this.getLocalSession(sessionEncKey)) ?? {}; localSession[key] = obj; await this.setLocalSession(localSession, sessionEncKey); } - async remove(key: string): Promise { - await this.save(key, null); - } - async getLocalSession(encKey: SymmetricCryptoKey): Promise> { - const local = await this.localStorage.get(keys.sessionKey); + const local = await this.localStorage.get(this.sessionKey); if (local == null) { return null; @@ -100,7 +134,7 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi if (sessionJson == null) { // Error with decryption -- session is lost, delete state and key and start over await this.setSessionEncKey(null); - await this.localStorage.remove(keys.sessionKey); + await this.localStorage.remove(this.sessionKey); return null; } return JSON.parse(sessionJson); @@ -119,9 +153,9 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi // Make sure we're storing the jsonified version of the session const jsonSession = JSON.parse(JSON.stringify(session)); if (session == null) { - await this.localStorage.remove(keys.sessionKey); + await this.localStorage.remove(this.sessionKey); } else { - await this.localStorage.save(keys.sessionKey, jsonSession); + await this.localStorage.save(this.sessionKey, jsonSession); } } @@ -130,13 +164,13 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi const encSession = await this.encryptService.encrypt(jsonSession, key); if (encSession == null) { - return await this.localStorage.remove(keys.sessionKey); + return await this.localStorage.remove(this.sessionKey); } - await this.localStorage.save(keys.sessionKey, encSession.encryptedString); + await this.localStorage.save(this.sessionKey, encSession.encryptedString); } async getSessionEncKey(): Promise { - let storedKey = await this.sessionStorage.get(keys.encKey); + let storedKey = await this.sessionStorage.get(this.encKey); if (storedKey == null || Object.keys(storedKey).length == 0) { const generatedKey = await this.keyGenerationService.createKeyWithPurpose( 128, @@ -153,9 +187,9 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi async setSessionEncKey(input: SymmetricCryptoKey): Promise { if (input == null) { - await this.sessionStorage.remove(keys.encKey); + await this.sessionStorage.remove(this.encKey); } else { - await this.sessionStorage.save(keys.encKey, input); + await this.sessionStorage.save(this.encKey, input); } } } diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index aec8ba7c66b..e0d898481bb 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -12,6 +12,7 @@ import { BrowserApi } from "../platform/browser/browser-api"; import { ZonedMessageListenerService } from "../platform/browser/zoned-message-listener.service"; import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; import { ForegroundPlatformUtilsService } from "../platform/services/platform-utils/foreground-platform-utils.service"; +import { VaultBrowserStateService } from "../vault/services/vault-browser-state.service"; import { routerTransition } from "./app-routing.animations"; import { DesktopSyncVerificationDialogComponent } from "./components/desktop-sync-verification-dialog.component"; @@ -37,6 +38,7 @@ export class AppComponent implements OnInit, OnDestroy { private i18nService: I18nService, private router: Router, private stateService: BrowserStateService, + private vaultBrowserStateService: VaultBrowserStateService, private changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, private platformUtilsService: ForegroundPlatformUtilsService, @@ -140,7 +142,7 @@ export class AppComponent implements OnInit, OnDestroy { } }; - (window as any).bitwardenPopupMainMessageListener = bitwardenPopupMainMessageListener; + (self as any).bitwardenPopupMainMessageListener = bitwardenPopupMainMessageListener; this.browserMessagingApi.messageListener("app.component", bitwardenPopupMainMessageListener); // eslint-disable-next-line rxjs/no-async-subscribe @@ -227,8 +229,8 @@ export class AppComponent implements OnInit, OnDestroy { } await Promise.all([ - this.stateService.setBrowserGroupingComponentState(null), - this.stateService.setBrowserVaultItemsComponentState(null), + this.vaultBrowserStateService.setBrowserGroupingsComponentState(null), + this.vaultBrowserStateService.setBrowserVaultItemsComponentState(null), this.stateService.setBrowserSendComponentState(null), this.stateService.setBrowserSendTypeComponentState(null), ]); diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index b0e80ab9606..4036ace31fd 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -5,7 +5,6 @@ import { AbstractThemingService } from "@bitwarden/angular/platform/services/the import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; import { BrowserApi } from "../../platform/browser/browser-api"; import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; @@ -19,7 +18,6 @@ export class InitService { private stateService: StateServiceAbstraction, private logService: LogServiceAbstraction, private themingService: AbstractThemingService, - private configService: ConfigService, @Inject(DOCUMENT) private document: Document, ) {} @@ -55,7 +53,6 @@ export class InitService { this.logService.info("Force redraw is on"); } - this.configService.init(); this.setupVaultPopupHeartbeat(); }; } diff --git a/apps/browser/src/popup/services/popup-search.service.ts b/apps/browser/src/popup/services/popup-search.service.ts index 7eea1265a23..bc5e565e6ca 100644 --- a/apps/browser/src/popup/services/popup-search.service.ts +++ b/apps/browser/src/popup/services/popup-search.service.ts @@ -1,14 +1,14 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SearchService } from "@bitwarden/common/services/search.service"; export class PopupSearchService extends SearchService { constructor( private mainSearchService: SearchService, - consoleLogService: ConsoleLogService, + logService: LogService, i18nService: I18nService, ) { - super(consoleLogService, i18nService); + super(logService, i18nService); } clearIndex() { diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 33fe6a52af6..6d0f73f2067 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -1,22 +1,24 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; import { DomSanitizer } from "@angular/platform-browser"; +import { Router } from "@angular/router"; import { ToastrService } from "ngx-toastr"; import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards"; import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service"; +import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { MEMORY_STORAGE, SECURE_STORAGE, OBSERVABLE_DISK_STORAGE, OBSERVABLE_MEMORY_STORAGE, SYSTEM_THEME_OBSERVABLE, + SafeInjectionToken, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { AuthRequestServiceAbstraction, LoginStrategyServiceAbstraction, } from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; @@ -28,13 +30,11 @@ import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/ab import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; -import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; -import { LoginService } from "@bitwarden/common/auth/services/login.service"; import { AutofillSettingsService, AutofillSettingsServiceAbstraction, @@ -47,19 +47,13 @@ import { UserNotificationSettingsService, UserNotificationSettingsServiceAbstraction, } from "@bitwarden/common/autofill/services/user-notification-settings.service"; -import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; -import { FileUploadService } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; -import { - LogService, - LogService as LogServiceAbstraction, -} from "@bitwarden/common/platform/abstractions/log.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; @@ -69,7 +63,6 @@ import { } from "@bitwarden/common/platform/abstractions/storage.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; -import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; @@ -82,12 +75,6 @@ import { import { SearchService } from "@bitwarden/common/services/search.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; -import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; -import { - InternalSendService as InternalSendServiceAbstraction, - SendService, -} from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; @@ -103,10 +90,9 @@ import MainBackground from "../../background/main.background"; import { Account } from "../../models/account"; import { BrowserApi } from "../../platform/browser/browser-api"; import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; +import { BrowserFileDownloadService } from "../../platform/popup/services/browser-file-download.service"; import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service"; -import { BrowserConfigService } from "../../platform/services/browser-config.service"; import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service"; -import { BrowserFileDownloadService } from "../../platform/services/browser-file-download.service"; import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service"; import BrowserMessagingPrivateModePopupService from "../../platform/services/browser-messaging-private-mode-popup.service"; import BrowserMessagingService from "../../platform/services/browser-messaging.service"; @@ -115,8 +101,8 @@ import I18nService from "../../platform/services/i18n.service"; import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service"; import { ForegroundDerivedStateProvider } from "../../platform/state/foreground-derived-state.provider"; import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; -import { BrowserSendService } from "../../services/browser-send.service"; import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; +import { VaultBrowserStateService } from "../../vault/services/vault-browser-state.service"; import { VaultFilterService } from "../../vault/services/vault-filter.service"; import { DebounceNavigationService } from "./debounce-navigation.service"; @@ -145,367 +131,353 @@ function getBgService(service: keyof MainBackground) { }; } +/** + * Provider definitions used in the ngModule. + * Add your provider definition here using the safeProvider function as a wrapper. This will give you type safety. + * If you need help please ask for it, do NOT change the type of this array. + */ +const safeProviders: SafeProvider[] = [ + safeProvider(InitService), + safeProvider(DebounceNavigationService), + safeProvider(DialogService), + safeProvider(PopupCloseWarningService), + safeProvider({ + provide: APP_INITIALIZER as SafeInjectionToken<() => Promise>, + useFactory: (initService: InitService) => initService.init(), + deps: [InitService], + multi: true, + }), + safeProvider({ + provide: BaseUnauthGuardService, + useClass: UnauthGuardService, + deps: [AuthServiceAbstraction, Router], + }), + safeProvider({ + provide: MessagingService, + useFactory: () => { + return needsBackgroundInit + ? new BrowserMessagingPrivateModePopupService() + : new BrowserMessagingService(); + }, + deps: [], + }), + safeProvider({ + provide: TwoFactorService, + useFactory: getBgService("twoFactorService"), + deps: [], + }), + safeProvider({ + provide: AuthServiceAbstraction, + useFactory: getBgService("authService"), + deps: [], + }), + safeProvider({ + provide: LoginStrategyServiceAbstraction, + useFactory: getBgService("loginStrategyService"), + deps: [], + }), + safeProvider({ + provide: SsoLoginServiceAbstraction, + useFactory: getBgService("ssoLoginService"), + deps: [], + }), + safeProvider({ + provide: SearchServiceAbstraction, + useFactory: (logService: LogService, i18nService: I18nServiceAbstraction) => { + return new PopupSearchService( + getBgService("searchService")(), + logService, + i18nService, + ); + }, + deps: [LogService, I18nServiceAbstraction], + }), + safeProvider({ + provide: CipherFileUploadService, + useFactory: getBgService("cipherFileUploadService"), + deps: [], + }), + safeProvider({ + provide: CipherService, + useFactory: getBgService("cipherService"), + deps: [], + }), + safeProvider({ + provide: CryptoFunctionService, + useFactory: () => new WebCryptoFunctionService(window), + deps: [], + }), + safeProvider({ + provide: CollectionService, + useFactory: getBgService("collectionService"), + deps: [], + }), + safeProvider({ + provide: LogService, + useFactory: (platformUtilsService: PlatformUtilsService) => + new ConsoleLogService(platformUtilsService.isDev()), + deps: [PlatformUtilsService], + }), + safeProvider({ + provide: EnvironmentService, + useExisting: BrowserEnvironmentService, + }), + safeProvider({ + provide: BrowserEnvironmentService, + useClass: BrowserEnvironmentService, + deps: [LogService, StateProvider, AccountServiceAbstraction], + }), + safeProvider({ + provide: TotpService, + useFactory: getBgService("totpService"), + deps: [], + }), + safeProvider({ + provide: I18nServiceAbstraction, + useFactory: (globalStateProvider: GlobalStateProvider) => { + return new I18nService(BrowserApi.getUILanguage(), globalStateProvider); + }, + deps: [GlobalStateProvider], + }), + safeProvider({ + provide: CryptoService, + useFactory: (encryptService: EncryptService) => { + const cryptoService = getBgService("cryptoService")(); + new ContainerService(cryptoService, encryptService).attachToGlobal(self); + return cryptoService; + }, + deps: [EncryptService], + }), + safeProvider({ + provide: AuthRequestServiceAbstraction, + useFactory: getBgService("authRequestService"), + deps: [], + }), + safeProvider({ + provide: DeviceTrustCryptoServiceAbstraction, + useFactory: getBgService("deviceTrustCryptoService"), + deps: [], + }), + safeProvider({ + provide: DevicesServiceAbstraction, + useFactory: getBgService("devicesService"), + deps: [], + }), + safeProvider({ + provide: PlatformUtilsService, + useExisting: ForegroundPlatformUtilsService, + }), + safeProvider({ + provide: ForegroundPlatformUtilsService, + useClass: ForegroundPlatformUtilsService, + useFactory: (sanitizer: DomSanitizer, toastrService: ToastrService) => { + return new ForegroundPlatformUtilsService( + sanitizer, + toastrService, + (clipboardValue: string, clearMs: number) => { + void BrowserApi.sendMessage("clearClipboard", { clipboardValue, clearMs }); + }, + async () => { + const response = await BrowserApi.sendMessageWithResponse<{ + result: boolean; + error: string; + }>("biometricUnlock"); + if (!response.result) { + throw response.error; + } + return response.result; + }, + window, + ); + }, + deps: [DomSanitizer, ToastrService], + }), + safeProvider({ + provide: PasswordGenerationServiceAbstraction, + useFactory: getBgService("passwordGenerationService"), + deps: [], + }), + safeProvider({ + provide: SyncService, + useFactory: getBgService("syncService"), + deps: [], + }), + safeProvider({ + provide: DomainSettingsService, + useClass: DefaultDomainSettingsService, + deps: [StateProvider], + }), + safeProvider({ + provide: AbstractStorageService, + useClass: BrowserLocalStorageService, + deps: [], + }), + safeProvider({ + provide: AutofillService, + useFactory: getBgService("autofillService"), + deps: [], + }), + safeProvider({ + provide: VaultExportServiceAbstraction, + useFactory: getBgService("exportService"), + deps: [], + }), + safeProvider({ + provide: KeyConnectorService, + useFactory: getBgService("keyConnectorService"), + deps: [], + }), + safeProvider({ + provide: UserVerificationService, + useFactory: getBgService("userVerificationService"), + deps: [], + }), + safeProvider({ + provide: VaultTimeoutSettingsService, + useFactory: getBgService("vaultTimeoutSettingsService"), + deps: [], + }), + safeProvider({ + provide: VaultTimeoutService, + useFactory: getBgService("vaultTimeoutService"), + deps: [], + }), + safeProvider({ + provide: NotificationsService, + useFactory: getBgService("notificationsService"), + deps: [], + }), + safeProvider({ + provide: VaultFilterService, + useClass: VaultFilterService, + deps: [ + OrganizationService, + FolderServiceAbstraction, + CipherService, + CollectionService, + PolicyService, + StateProvider, + AccountServiceAbstraction, + ], + }), + safeProvider({ + provide: SECURE_STORAGE, + useExisting: AbstractStorageService, // Secure storage is not available in the browser, so we use normal storage instead and warn users when it is used. + }), + safeProvider({ + provide: MEMORY_STORAGE, + useFactory: getBgService("memoryStorageService"), + deps: [], + }), + safeProvider({ + provide: OBSERVABLE_MEMORY_STORAGE, + useClass: ForegroundMemoryStorageService, + deps: [], + }), + safeProvider({ + provide: OBSERVABLE_DISK_STORAGE, + useExisting: AbstractStorageService, + }), + safeProvider({ + provide: VaultBrowserStateService, + useFactory: (stateProvider: StateProvider) => { + return new VaultBrowserStateService(stateProvider); + }, + deps: [StateProvider], + }), + safeProvider({ + provide: StateServiceAbstraction, + useFactory: ( + storageService: AbstractStorageService, + secureStorageService: AbstractStorageService, + memoryStorageService: AbstractMemoryStorageService, + logService: LogService, + accountService: AccountServiceAbstraction, + environmentService: EnvironmentService, + tokenService: TokenService, + migrationRunner: MigrationRunner, + ) => { + return new BrowserStateService( + storageService, + secureStorageService, + memoryStorageService, + logService, + new StateFactory(GlobalState, Account), + accountService, + environmentService, + tokenService, + migrationRunner, + ); + }, + deps: [ + AbstractStorageService, + SECURE_STORAGE, + MEMORY_STORAGE, + LogService, + AccountServiceAbstraction, + EnvironmentService, + TokenService, + MigrationRunner, + ], + }), + safeProvider({ + provide: UsernameGenerationServiceAbstraction, + useFactory: getBgService("usernameGenerationService"), + deps: [], + }), + safeProvider({ + provide: BaseStateServiceAbstraction, + useExisting: StateServiceAbstraction, + deps: [], + }), + safeProvider({ + provide: FileDownloadService, + useClass: BrowserFileDownloadService, + deps: [], + }), + safeProvider({ + provide: SYSTEM_THEME_OBSERVABLE, + useFactory: (platformUtilsService: PlatformUtilsService) => { + // Safari doesn't properly handle the (prefers-color-scheme) media query in the popup window, it always returns light. + // In Safari, we have to use the background page instead, which comes with limitations like not dynamically changing the extension theme when the system theme is changed. + let windowContext = window; + const backgroundWindow = BrowserApi.getBackgroundPage(); + if (platformUtilsService.isSafari() && backgroundWindow) { + windowContext = backgroundWindow; + } + + return AngularThemingService.createSystemThemeFromWindow(windowContext); + }, + deps: [PlatformUtilsService], + }), + safeProvider({ + provide: FilePopoutUtilsService, + useFactory: (platformUtilsService: PlatformUtilsService) => { + return new FilePopoutUtilsService(platformUtilsService); + }, + deps: [PlatformUtilsService], + }), + safeProvider({ + provide: DerivedStateProvider, + useClass: ForegroundDerivedStateProvider, + deps: [OBSERVABLE_MEMORY_STORAGE, NgZone], + }), + safeProvider({ + provide: AutofillSettingsServiceAbstraction, + useClass: AutofillSettingsService, + deps: [StateProvider, PolicyService], + }), + safeProvider({ + provide: UserNotificationSettingsServiceAbstraction, + useClass: UserNotificationSettingsService, + deps: [StateProvider], + }), +]; + @NgModule({ imports: [JslibServicesModule], declarations: [], - providers: [ - InitService, - DebounceNavigationService, - DialogService, - PopupCloseWarningService, - { - provide: APP_INITIALIZER, - useFactory: (initService: InitService) => initService.init(), - deps: [InitService], - multi: true, - }, - { provide: BaseUnauthGuardService, useClass: UnauthGuardService }, - { - provide: MessagingService, - useFactory: () => { - return needsBackgroundInit - ? new BrowserMessagingPrivateModePopupService() - : new BrowserMessagingService(); - }, - }, - { - provide: TwoFactorService, - useFactory: getBgService("twoFactorService"), - deps: [], - }, - { - provide: AuthServiceAbstraction, - useFactory: getBgService("authService"), - deps: [], - }, - { - provide: LoginStrategyServiceAbstraction, - useFactory: getBgService("loginStrategyService"), - }, - { - provide: SsoLoginServiceAbstraction, - useFactory: getBgService("ssoLoginService"), - deps: [], - }, - { - provide: SearchServiceAbstraction, - useFactory: (logService: ConsoleLogService, i18nService: I18nServiceAbstraction) => { - return new PopupSearchService( - getBgService("searchService")(), - logService, - i18nService, - ); - }, - deps: [LogServiceAbstraction, I18nServiceAbstraction], - }, - { - provide: CipherFileUploadService, - useFactory: getBgService("cipherFileUploadService"), - deps: [], - }, - { provide: CipherService, useFactory: getBgService("cipherService"), deps: [] }, - { - provide: CryptoFunctionService, - useFactory: () => new WebCryptoFunctionService(window), - deps: [], - }, - { - provide: CollectionService, - useFactory: getBgService("collectionService"), - deps: [], - }, - { - provide: LogServiceAbstraction, - useFactory: (platformUtilsService: PlatformUtilsService) => - new ConsoleLogService(platformUtilsService.isDev()), - deps: [PlatformUtilsService], - }, - { - provide: BrowserEnvironmentService, - useClass: BrowserEnvironmentService, - deps: [LogService, StateProvider, AccountServiceAbstraction], - }, - { - provide: EnvironmentService, - useExisting: BrowserEnvironmentService, - }, - { provide: TotpService, useFactory: getBgService("totpService"), deps: [] }, - { - provide: I18nServiceAbstraction, - useFactory: (globalStateProvider: GlobalStateProvider) => { - return new I18nService(BrowserApi.getUILanguage(), globalStateProvider); - }, - deps: [GlobalStateProvider], - }, - { - provide: CryptoService, - useFactory: (encryptService: EncryptService) => { - const cryptoService = getBgService("cryptoService")(); - new ContainerService(cryptoService, encryptService).attachToGlobal(self); - return cryptoService; - }, - deps: [EncryptService], - }, - { - provide: AuthRequestServiceAbstraction, - useFactory: getBgService("authRequestService"), - deps: [], - }, - { - provide: DeviceTrustCryptoServiceAbstraction, - useFactory: getBgService("deviceTrustCryptoService"), - deps: [], - }, - { - provide: DevicesServiceAbstraction, - useFactory: getBgService("devicesService"), - deps: [], - }, - { - provide: PlatformUtilsService, - useExisting: ForegroundPlatformUtilsService, - }, - { - provide: ForegroundPlatformUtilsService, - useClass: ForegroundPlatformUtilsService, - useFactory: (sanitizer: DomSanitizer, toastrService: ToastrService) => { - return new ForegroundPlatformUtilsService( - sanitizer, - toastrService, - (clipboardValue: string, clearMs: number) => { - void BrowserApi.sendMessage("clearClipboard", { clipboardValue, clearMs }); - }, - async () => { - const response = await BrowserApi.sendMessageWithResponse<{ - result: boolean; - error: string; - }>("biometricUnlock"); - if (!response.result) { - throw response.error; - } - return response.result; - }, - window, - ); - }, - deps: [DomSanitizer, ToastrService], - }, - { - provide: PasswordGenerationServiceAbstraction, - useFactory: getBgService("passwordGenerationService"), - deps: [], - }, - { - provide: SendService, - useFactory: ( - cryptoService: CryptoService, - i18nService: I18nServiceAbstraction, - keyGenerationService: KeyGenerationService, - stateServiceAbstraction: StateServiceAbstraction, - ) => { - return new BrowserSendService( - cryptoService, - i18nService, - keyGenerationService, - stateServiceAbstraction, - ); - }, - deps: [CryptoService, I18nServiceAbstraction, KeyGenerationService, StateServiceAbstraction], - }, - { - provide: InternalSendServiceAbstraction, - useExisting: SendService, - }, - { - provide: SendApiServiceAbstraction, - useFactory: ( - apiService: ApiService, - fileUploadService: FileUploadService, - sendService: InternalSendServiceAbstraction, - ) => { - return new SendApiService(apiService, fileUploadService, sendService); - }, - deps: [ApiService, FileUploadService, InternalSendServiceAbstraction], - }, - { provide: SyncService, useFactory: getBgService("syncService"), deps: [] }, - { - provide: DomainSettingsService, - useClass: DefaultDomainSettingsService, - deps: [StateProvider], - }, - { - provide: AbstractStorageService, - useClass: BrowserLocalStorageService, - deps: [], - }, - { - provide: AutofillService, - useFactory: getBgService("autofillService"), - deps: [], - }, - { - provide: VaultExportServiceAbstraction, - useFactory: getBgService("exportService"), - deps: [], - }, - { - provide: KeyConnectorService, - useFactory: getBgService("keyConnectorService"), - deps: [], - }, - { - provide: UserVerificationService, - useFactory: getBgService("userVerificationService"), - deps: [], - }, - { - provide: VaultTimeoutSettingsService, - useFactory: getBgService("vaultTimeoutSettingsService"), - deps: [], - }, - { - provide: VaultTimeoutService, - useFactory: getBgService("vaultTimeoutService"), - deps: [], - }, - { - provide: NotificationsService, - useFactory: getBgService("notificationsService"), - deps: [], - }, - { - provide: VaultFilterService, - useClass: VaultFilterService, - deps: [ - OrganizationService, - FolderServiceAbstraction, - CipherService, - CollectionService, - PolicyService, - StateProvider, - AccountServiceAbstraction, - ], - }, - { - provide: SECURE_STORAGE, - useExisting: AbstractStorageService, // Secure storage is not available in the browser, so we use normal storage instead and warn users when it is used. - }, - { - provide: MEMORY_STORAGE, - useFactory: getBgService("memoryStorageService"), - }, - { - provide: OBSERVABLE_MEMORY_STORAGE, - useClass: ForegroundMemoryStorageService, - deps: [], - }, - { - provide: OBSERVABLE_DISK_STORAGE, - useExisting: AbstractStorageService, - }, - { - provide: StateServiceAbstraction, - useFactory: ( - storageService: AbstractStorageService, - secureStorageService: AbstractStorageService, - memoryStorageService: AbstractMemoryStorageService, - logService: LogServiceAbstraction, - accountService: AccountServiceAbstraction, - environmentService: EnvironmentService, - tokenService: TokenService, - migrationRunner: MigrationRunner, - ) => { - return new BrowserStateService( - storageService, - secureStorageService, - memoryStorageService, - logService, - new StateFactory(GlobalState, Account), - accountService, - environmentService, - tokenService, - migrationRunner, - ); - }, - deps: [ - AbstractStorageService, - SECURE_STORAGE, - MEMORY_STORAGE, - LogServiceAbstraction, - AccountServiceAbstraction, - EnvironmentService, - TokenService, - MigrationRunner, - ], - }, - { - provide: UsernameGenerationServiceAbstraction, - useFactory: getBgService("usernameGenerationService"), - deps: [], - }, - { - provide: BaseStateServiceAbstraction, - useExisting: StateServiceAbstraction, - deps: [], - }, - { - provide: FileDownloadService, - useClass: BrowserFileDownloadService, - }, - { - provide: LoginServiceAbstraction, - useClass: LoginService, - deps: [StateServiceAbstraction], - }, - { - provide: SYSTEM_THEME_OBSERVABLE, - useFactory: (platformUtilsService: PlatformUtilsService) => { - // Safari doesn't properly handle the (prefers-color-scheme) media query in the popup window, it always returns light. - // In Safari, we have to use the background page instead, which comes with limitations like not dynamically changing the extension theme when the system theme is changed. - let windowContext = window; - const backgroundWindow = BrowserApi.getBackgroundPage(); - if (platformUtilsService.isSafari() && backgroundWindow) { - windowContext = backgroundWindow; - } - - return AngularThemingService.createSystemThemeFromWindow(windowContext); - }, - deps: [PlatformUtilsService], - }, - { - provide: ConfigService, - useClass: BrowserConfigService, - deps: [ - StateServiceAbstraction, - ConfigApiServiceAbstraction, - AuthServiceAbstraction, - EnvironmentService, - StateProvider, - LogService, - ], - }, - { - provide: FilePopoutUtilsService, - useFactory: (platformUtilsService: PlatformUtilsService) => { - return new FilePopoutUtilsService(platformUtilsService); - }, - deps: [PlatformUtilsService], - }, - { - provide: DerivedStateProvider, - useClass: ForegroundDerivedStateProvider, - deps: [OBSERVABLE_MEMORY_STORAGE, NgZone], - }, - { - provide: AutofillSettingsServiceAbstraction, - useClass: AutofillSettingsService, - deps: [StateProvider, PolicyService], - }, - { - provide: UserNotificationSettingsServiceAbstraction, - useClass: UserNotificationSettingsService, - deps: [StateProvider], - }, - ], + // Do not register your dependency here! Add it to the typesafeProviders array using the helper function + providers: safeProviders, }) export class ServicesModule {} diff --git a/apps/browser/src/popup/settings/about.component.ts b/apps/browser/src/popup/settings/about.component.ts index 4cabb183aee..61b5749b513 100644 --- a/apps/browser/src/popup/settings/about.component.ts +++ b/apps/browser/src/popup/settings/about.component.ts @@ -3,7 +3,7 @@ import { Component } from "@angular/core"; import { combineLatest, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { ButtonModule, DialogModule } from "@bitwarden/components"; @@ -24,7 +24,7 @@ export class AboutComponent { ]).pipe(map(([serverConfig, isCloud]) => ({ serverConfig, isCloud }))); constructor( - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private environmentService: EnvironmentService, ) {} } diff --git a/apps/browser/src/services/browser-send.service.ts b/apps/browser/src/services/browser-send.service.ts deleted file mode 100644 index 8a197444a98..00000000000 --- a/apps/browser/src/services/browser-send.service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { BehaviorSubject } from "rxjs"; - -import { Send } from "@bitwarden/common/tools/send/models/domain/send"; -import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; -import { SendService } from "@bitwarden/common/tools/send/services/send.service"; - -import { browserSession, sessionSync } from "../platform/decorators/session-sync-observable"; - -@browserSession -export class BrowserSendService extends SendService { - @sessionSync({ initializer: Send.fromJSON, initializeAs: "array" }) - protected _sends: BehaviorSubject; - @sessionSync({ initializer: SendView.fromJSON, initializeAs: "array" }) - protected _sendViews: BehaviorSubject; -} diff --git a/apps/browser/src/tools/background/fileless-importer.background.spec.ts b/apps/browser/src/tools/background/fileless-importer.background.spec.ts index d3436099ef1..858889b8874 100644 --- a/apps/browser/src/tools/background/fileless-importer.background.spec.ts +++ b/apps/browser/src/tools/background/fileless-importer.background.spec.ts @@ -4,7 +4,7 @@ import { firstValueFrom } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; -import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Importer, ImportResult, ImportServiceAbstraction } from "@bitwarden/importer/core"; diff --git a/apps/browser/src/tools/background/fileless-importer.background.ts b/apps/browser/src/tools/background/fileless-importer.background.ts index 3ddc7bd1b76..57c2faa930b 100644 --- a/apps/browser/src/tools/background/fileless-importer.background.ts +++ b/apps/browser/src/tools/background/fileless-importer.background.ts @@ -5,7 +5,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { ImportServiceAbstraction } from "@bitwarden/importer/core"; @@ -55,7 +55,7 @@ class FilelessImporterBackground implements FilelessImporterBackgroundInterface * @param syncService - Used to trigger a full sync after the import is completed. */ constructor( - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private authService: AuthService, private policyService: PolicyService, private notificationBackground: NotificationBackground, diff --git a/apps/browser/src/tools/popup/settings/export.component.ts b/apps/browser/src/tools/popup/settings/export.component.ts index 70735b5184d..b62ed4c517f 100644 --- a/apps/browser/src/tools/popup/settings/export.component.ts +++ b/apps/browser/src/tools/popup/settings/export.component.ts @@ -2,7 +2,6 @@ import { Component } from "@angular/core"; import { UntypedFormBuilder } from "@angular/forms"; import { Router } from "@angular/router"; -import { ExportComponent as BaseExportComponent } from "@bitwarden/angular/tools/export/components/export.component"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -13,6 +12,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; +import { ExportComponent as BaseExportComponent } from "@bitwarden/vault-export-ui"; @Component({ selector: "app-export", diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.html b/apps/browser/src/vault/popup/components/vault/add-edit.component.html index ecdeb9cda72..8ff448b6f76 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.html +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.html @@ -138,10 +138,20 @@ attr.aria-label="{{ 'typePasskey' | i18n }} {{ fido2CredentialCreationDateValue }}" >
-
- {{ "typePasskey" | i18n }} - {{ "dateCreated" | i18n }} - {{ cipher.login.fido2Credentials[0].creationDate | date: "short" }} +
+ +
+ {{ "typePasskey" | i18n }} + {{ "dateCreated" | i18n }} + {{ cipher.login.fido2Credentials[0].creationDate | date: "short" }} +
diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts index 8e52d44069b..b27a9862311 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts @@ -11,7 +11,7 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve 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 { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -68,7 +68,7 @@ export class AddEditComponent extends BaseAddEditComponent { sendApiService: SendApiService, dialogService: DialogService, datePipe: DatePipe, - configService: ConfigServiceAbstraction, + configService: ConfigService, ) { super( cipherService, diff --git a/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts b/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts index 5e7959b38f8..2510e2f966b 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts @@ -20,7 +20,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { BrowserGroupingsComponentState } from "../../../../models/browserGroupingsComponentState"; import { BrowserApi } from "../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; -import { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service"; +import { VaultBrowserStateService } from "../../../services/vault-browser-state.service"; import { VaultFilterService } from "../../../services/vault-filter.service"; const ComponentId = "VaultComponent"; @@ -84,8 +84,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { private platformUtilsService: PlatformUtilsService, private searchService: SearchService, private location: Location, - private browserStateService: BrowserStateService, private vaultFilterService: VaultFilterService, + private vaultBrowserStateService: VaultBrowserStateService, ) { this.noFolderListSize = 100; } @@ -95,7 +95,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { this.showLeftHeader = !( BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox() ); - await this.browserStateService.setBrowserVaultItemsComponentState(null); + await this.vaultBrowserStateService.setBrowserVaultItemsComponentState(null); this.broadcasterService.subscribe(ComponentId, (message: any) => { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -120,7 +120,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { const restoredScopeState = await this.restoreState(); // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.queryParams.pipe(first()).subscribe(async (params) => { - this.state = await this.browserStateService.getBrowserGroupingComponentState(); + this.state = await this.vaultBrowserStateService.getBrowserGroupingsComponentState(); if (this.state?.searchText) { this.searchText = this.state.searchText; } else if (params.searchText) { @@ -413,11 +413,11 @@ export class VaultFilterComponent implements OnInit, OnDestroy { collections: this.collections, deletedCount: this.deletedCount, }); - await this.browserStateService.setBrowserGroupingComponentState(this.state); + await this.vaultBrowserStateService.setBrowserGroupingsComponentState(this.state); } private async restoreState(): Promise { - this.state = await this.browserStateService.getBrowserGroupingComponentState(); + this.state = await this.vaultBrowserStateService.getBrowserGroupingsComponentState(); if (this.state == null) { return false; } diff --git a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts index 96d5fe170b0..abb810c04d5 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts @@ -21,7 +21,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { BrowserComponentState } from "../../../../models/browserComponentState"; import { BrowserApi } from "../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; -import { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service"; +import { VaultBrowserStateService } from "../../../services/vault-browser-state.service"; import { VaultFilterService } from "../../../services/vault-filter.service"; const ComponentId = "VaultItemsComponent"; @@ -59,7 +59,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn private ngZone: NgZone, private broadcasterService: BroadcasterService, private changeDetectorRef: ChangeDetectorRef, - private stateService: BrowserStateService, + private stateService: VaultBrowserStateService, private i18nService: I18nService, private collectionService: CollectionService, private platformUtilsService: PlatformUtilsService, diff --git a/apps/browser/src/vault/services/vault-browser-state.service.spec.ts b/apps/browser/src/vault/services/vault-browser-state.service.spec.ts new file mode 100644 index 00000000000..b9369aa826b --- /dev/null +++ b/apps/browser/src/vault/services/vault-browser-state.service.spec.ts @@ -0,0 +1,87 @@ +import { + FakeAccountService, + mockAccountServiceWith, +} from "@bitwarden/common/../spec/fake-account-service"; +import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider"; +import { Jsonify } from "type-fest"; + +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/common/vault/enums"; + +import { BrowserComponentState } from "../../models/browserComponentState"; +import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; + +import { + VAULT_BROWSER_COMPONENT, + VAULT_BROWSER_GROUPINGS_COMPONENT, + VaultBrowserStateService, +} from "./vault-browser-state.service"; + +describe("Vault Browser State Service", () => { + let stateProvider: FakeStateProvider; + + let accountService: FakeAccountService; + let stateService: VaultBrowserStateService; + const mockUserId = Utils.newGuid() as UserId; + + beforeEach(() => { + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + + stateService = new VaultBrowserStateService(stateProvider); + }); + + describe("getBrowserGroupingsComponentState", () => { + it("should return a BrowserGroupingsComponentState", async () => { + await stateService.setBrowserGroupingsComponentState(new BrowserGroupingsComponentState()); + + const actual = await stateService.getBrowserGroupingsComponentState(); + + expect(actual).toBeInstanceOf(BrowserGroupingsComponentState); + }); + + it("should deserialize BrowserGroupingsComponentState", () => { + const sut = VAULT_BROWSER_GROUPINGS_COMPONENT; + + const expectedState = { + deletedCount: 0, + collectionCounts: new Map(), + folderCounts: new Map(), + typeCounts: new Map(), + }; + + const result = sut.deserializer( + JSON.parse(JSON.stringify(expectedState)) as Jsonify, + ); + + expect(result).toEqual(expectedState); + }); + }); + + describe("getBrowserVaultItemsComponentState", () => { + it("should deserialize BrowserComponentState", () => { + const sut = VAULT_BROWSER_COMPONENT; + + const expectedState = { + scrollY: 0, + searchText: "test", + }; + + const result = sut.deserializer(JSON.parse(JSON.stringify(expectedState))); + + expect(result).toEqual(expectedState); + }); + + it("should return a BrowserComponentState", async () => { + const componentState = new BrowserComponentState(); + componentState.scrollY = 0; + componentState.searchText = "test"; + + await stateService.setBrowserVaultItemsComponentState(componentState); + + const actual = await stateService.getBrowserVaultItemsComponentState(); + expect(actual).toStrictEqual(componentState); + }); + }); +}); diff --git a/apps/browser/src/vault/services/vault-browser-state.service.ts b/apps/browser/src/vault/services/vault-browser-state.service.ts new file mode 100644 index 00000000000..a0d55a9d550 --- /dev/null +++ b/apps/browser/src/vault/services/vault-browser-state.service.ts @@ -0,0 +1,65 @@ +import { Observable, firstValueFrom } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { + ActiveUserState, + KeyDefinition, + StateProvider, + VAULT_BROWSER_MEMORY, +} from "@bitwarden/common/platform/state"; + +import { BrowserComponentState } from "../../models/browserComponentState"; +import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; + +export const VAULT_BROWSER_GROUPINGS_COMPONENT = new KeyDefinition( + VAULT_BROWSER_MEMORY, + "vault_browser_groupings_component", + { + deserializer: (obj: Jsonify) => + BrowserGroupingsComponentState.fromJSON(obj), + }, +); + +export const VAULT_BROWSER_COMPONENT = new KeyDefinition( + VAULT_BROWSER_MEMORY, + "vault_browser_component", + { + deserializer: (obj: Jsonify) => BrowserComponentState.fromJSON(obj), + }, +); + +export class VaultBrowserStateService { + vaultBrowserGroupingsComponentState$: Observable; + vaultBrowserComponentState$: Observable; + + private activeUserVaultBrowserGroupingsComponentState: ActiveUserState; + private activeUserVaultBrowserComponentState: ActiveUserState; + + constructor(protected stateProvider: StateProvider) { + this.activeUserVaultBrowserGroupingsComponentState = this.stateProvider.getActive( + VAULT_BROWSER_GROUPINGS_COMPONENT, + ); + this.activeUserVaultBrowserComponentState = + this.stateProvider.getActive(VAULT_BROWSER_COMPONENT); + + this.vaultBrowserGroupingsComponentState$ = + this.activeUserVaultBrowserGroupingsComponentState.state$; + this.vaultBrowserComponentState$ = this.activeUserVaultBrowserComponentState.state$; + } + + async getBrowserGroupingsComponentState(): Promise { + return await firstValueFrom(this.vaultBrowserGroupingsComponentState$); + } + + async setBrowserGroupingsComponentState(value: BrowserGroupingsComponentState): Promise { + await this.activeUserVaultBrowserGroupingsComponentState.update(() => value); + } + + async getBrowserVaultItemsComponentState(): Promise { + return await firstValueFrom(this.vaultBrowserComponentState$); + } + + async setBrowserVaultItemsComponentState(value: BrowserComponentState): Promise { + await this.activeUserVaultBrowserComponentState.update(() => value); + } +} diff --git a/apps/browser/store/locales/gl/copy.resx b/apps/browser/store/locales/gl/copy.resx index 191198691d4..d812256fb73 100644 --- a/apps/browser/store/locales/gl/copy.resx +++ b/apps/browser/store/locales/gl/copy.resx @@ -118,58 +118,58 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Bitwarden – Free Password Manager + Bitwarden – Xestor de contrasinais gratuíto - A secure and free password manager for all of your devices + Un xestor de contrasinais seguro e gratuíto para todos os teus dispositivos - Bitwarden, Inc. is the parent company of 8bit Solutions LLC. + Bitwarden, Inc. é a empresa matriz de 8bit Solutions LLC. -NAMED BEST PASSWORD MANAGER BY THE VERGE, U.S. NEWS & WORLD REPORT, CNET, AND MORE. +NOMEADO MELLOR ADMINISTRADOR DE CONTRASINAIS POR THE VERGE, Ou.S. NEWS & WORLD REPORT, CNET E MÁS. -Manage, store, secure, and share unlimited passwords across unlimited devices from anywhere. Bitwarden delivers open source password management solutions to everyone, whether at home, at work, or on the go. +Administre, almacene, protexa e comparta contrasinais ilimitados en dispositivos ilimitados desde calquera lugar. Bitwarden ofrece solucións de xestión de contrasinais de código aberto para todos, xa sexa en casa, no traballo ou en mentres estás de viaxe. -Generate strong, unique, and random passwords based on security requirements for every website you frequent. +Xere contrasinais seguros, únicas e aleatorias en función dos requisitos de seguridade de cada sitio web que frecuenta. -Bitwarden Send quickly transmits encrypted information --- files and plaintext -- directly to anyone. +Bitwarden Send transmite rapidamente información cifrada --- arquivos e texto sen formato, directamente a calquera persoa. -Bitwarden offers Teams and Enterprise plans for companies so you can securely share passwords with colleagues. +Bitwarden ofrece plans Teams e Enterprise para empresas para que poida compartir contrasinais de forma segura con colegas. -Why Choose Bitwarden: +Por que elixir Bitwarden? -World-Class Encryption -Passwords are protected with advanced end-to-end encryption (AES-256 bit, salted hashing, and PBKDF2 SHA-256) so your data stays secure and private. +Cifrado de clase mundial +Os contrasinais están protexidas con cifrado avanzado de extremo a extremo (AES-256 bits, salted hashing e PBKDF2 XA-256) para que os seus datos permanezan seguros e privados. -Built-in Password Generator -Generate strong, unique, and random passwords based on security requirements for every website you frequent. +Xerador de contrasinais incorporado +Xere contrasinais fortes, únicas e aleatorias en función dos requisitos de seguridade de cada sitio web que frecuenta. -Global Translations -Bitwarden translations exist in 40 languages and are growing, thanks to our global community. +Traducións Globais +As traducións de Bitwarden existen en 40 idiomas e están a crecer, grazas á nosa comunidade global. -Cross-Platform Applications -Secure and share sensitive data within your Bitwarden Vault from any browser, mobile device, or desktop OS, and more. +Aplicacións multiplataforma +Protexa e comparta datos confidenciais dentro da súa Caixa Forte de Bitwarden desde calquera navegador, dispositivo móbil ou sistema operativo de escritorio, e máis. - A secure and free password manager for all of your devices + Un xestor de contrasinais seguro e gratuíto para todos os teus dispositivos - Sync and access your vault from multiple devices + Sincroniza e accede á túa caixa forte desde múltiples dispositivos - Manage all your logins and passwords from a secure vault + Xestiona todos os teus usuarios e contrasinais desde unha caixa forte segura - Quickly auto-fill your login credentials into any website that you visit + Autocompleta rapidamente os teus datos de acceso en calquera páxina web que visites - Your vault is also conveniently accessible from the right-click menu + A túa caixa forte tamén é facilmente accesible desde o menú de clic dereito - Automatically generate strong, random, and secure passwords + Xera automaticamente contrasinais fortes, aleatorias e seguras - Your information is managed securely using AES-256 bit encryption + A túa información é xestionada de forma segura con cifrado AES de 256 bits diff --git a/apps/cli/package.json b/apps/cli/package.json index 2873be4242b..690842d831d 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.3.0", + "version": "2024.3.1", "keywords": [ "bitwarden", "password", @@ -71,7 +71,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.13", + "tldts": "6.1.16", "zxcvbn": "4.4.2" } } diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 7af40b1ebd9..3815fc773bc 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -47,6 +47,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { @@ -60,6 +61,7 @@ import { GlobalState } from "@bitwarden/common/platform/models/domain/global-sta import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/services/broadcaster.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; +import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { CryptoService } from "@bitwarden/common/platform/services/crypto.service"; import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; @@ -104,6 +106,7 @@ import { PasswordStrengthServiceAbstraction, } from "@bitwarden/common/tools/password-strength"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; +import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { UserId } from "@bitwarden/common/types/guid"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -131,7 +134,6 @@ import { VaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; -import { CliConfigService } from "./platform/services/cli-config.service"; import { CliPlatformUtilsService } from "./platform/services/cli-platform-utils.service"; import { ConsoleLogService } from "./platform/services/console-log.service"; import { I18nService } from "./platform/services/i18n.service"; @@ -193,6 +195,7 @@ export class Main { sendProgram: SendProgram; logService: ConsoleLogService; sendService: SendService; + sendStateProvider: SendStateProvider; fileUploadService: FileUploadService; cipherFileUploadService: CipherFileUploadService; keyConnectorService: KeyConnectorService; @@ -214,7 +217,7 @@ export class Main { deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction; authRequestService: AuthRequestService; configApiService: ConfigApiServiceAbstraction; - configService: CliConfigService; + configService: ConfigService; accountService: AccountService; globalStateProvider: GlobalStateProvider; singleUserStateProvider: SingleUserStateProvider; @@ -318,11 +321,16 @@ export class Main { this.accountService, ); + this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); + this.tokenService = new TokenService( this.singleUserStateProvider, this.globalStateProvider, this.platformUtilsService.supportsSecureStorage(), this.secureStorageService, + this.keyGenerationService, + this.encryptService, + this.logService, ); const migrationRunner = new MigrationRunner( @@ -343,8 +351,6 @@ export class Main { migrationRunner, ); - this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); - this.cryptoService = new CryptoService( this.keyGenerationService, this.cryptoFunctionService, @@ -384,11 +390,14 @@ export class Main { this.fileUploadService = new FileUploadService(this.logService); + this.sendStateProvider = new SendStateProvider(this.stateProvider); + this.sendService = new SendService( this.cryptoService, this.i18nService, this.keyGenerationService, - this.stateService, + this.sendStateProvider, + this.encryptService, ); this.cipherFileUploadService = new CipherFileUploadService( @@ -423,7 +432,6 @@ export class Main { this.policyApiService = new PolicyApiService(this.policyService, this.apiService); this.keyConnectorService = new KeyConnectorService( - this.stateService, this.cryptoService, this.apiService, this.tokenService, @@ -431,6 +439,7 @@ export class Main { this.organizationService, this.keyGenerationService, async (expired: boolean) => await this.logout(), + this.stateProvider, ); this.twoFactorService = new TwoFactorService(this.i18nService, this.platformUtilsService); @@ -451,11 +460,12 @@ export class Main { this.cryptoFunctionService, this.cryptoService, this.encryptService, - this.stateService, this.appIdService, this.devicesApiService, this.i18nService, this.platformUtilsService, + this.stateProvider, + this.secureStorageService, this.userDecryptionOptionsService, ); @@ -467,7 +477,7 @@ export class Main { ); this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService( - this.activeUserStateProvider, + this.stateProvider, ); this.loginStrategyService = new LoginStrategyService( @@ -494,22 +504,21 @@ export class Main { ); this.authService = new AuthService( + this.accountService, this.messagingService, this.cryptoService, this.apiService, this.stateService, + this.tokenService, ); - this.configApiService = new ConfigApiService(this.apiService, this.authService); + this.configApiService = new ConfigApiService(this.apiService, this.tokenService); - this.configService = new CliConfigService( - this.stateService, + this.configService = new DefaultConfigService( this.configApiService, - this.authService, this.environmentService, this.logService, this.stateProvider, - true, ); this.cipherService = new CipherService( @@ -710,13 +719,6 @@ export class Main { this.containerService.attachToGlobal(global); await this.i18nService.init(); this.twoFactorService.init(); - this.configService.init(); - - const installedVersion = await this.stateService.getInstalledVersion(); - const currentVersion = await this.platformUtilsService.getApplicationVersion(); - if (installedVersion == null || installedVersion !== currentVersion) { - await this.stateService.setInstalledVersion(currentVersion); - } } } diff --git a/apps/cli/src/platform/services/cli-config.service.ts b/apps/cli/src/platform/services/cli-config.service.ts deleted file mode 100644 index 6faa1b12e8a..00000000000 --- a/apps/cli/src/platform/services/cli-config.service.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NEVER } from "rxjs"; - -import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; - -export class CliConfigService extends ConfigService { - // The rxjs timer uses setTimeout/setInterval under the hood, which prevents the node process from exiting - // when the command is finished. Cli should never be alive long enough to use the timer, so we disable it. - protected refreshTimer$ = NEVER; -} diff --git a/apps/cli/src/tools/send/commands/remove-password.command.ts b/apps/cli/src/tools/send/commands/remove-password.command.ts index 1c7289bf08f..2613004a8ce 100644 --- a/apps/cli/src/tools/send/commands/remove-password.command.ts +++ b/apps/cli/src/tools/send/commands/remove-password.command.ts @@ -18,7 +18,7 @@ export class SendRemovePasswordCommand { try { await this.sendApiService.removePassword(id); - const updatedSend = await this.sendService.get(id); + const updatedSend = await firstValueFrom(this.sendService.get$(id)); const decSend = await updatedSend.decrypt(); const env = await firstValueFrom(this.environmentService.environment$); const webVaultUrl = env.getWebVaultUrl(); diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 89170f4cc38..446bce87a07 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -45,9 +45,9 @@ checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" [[package]] name = "arboard" -version = "3.3.0" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aafb29b107435aa276664c1db8954ac27a6e105cdad3c88287a199eb0e313c08" +checksum = "a2041f1943049c7978768d84e6d0fd95de98b76d6c4727b09e78ec253d29fa58" dependencies = [ "clipboard-win", "log", @@ -56,7 +56,6 @@ dependencies = [ "objc_id", "parking_lot", "thiserror", - "winapi", "wl-clipboard-rs", "x11rb", ] @@ -176,13 +175,11 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "4.5.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" +checksum = "d517d4b86184dbb111d3556a10f1c8a04da7428d2987bf1081602bf11c3aa9ee" dependencies = [ "error-code", - "str-buf", - "winapi", ] [[package]] @@ -375,13 +372,9 @@ dependencies = [ [[package]] name = "error-code" -version = "2.3.1" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" -dependencies = [ - "libc", - "str-buf", -] +checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" [[package]] name = "fastrand" @@ -476,12 +469,12 @@ dependencies = [ [[package]] name = "gethostname" -version = "0.3.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb65d4ba3173c56a500b555b532f72c42e8d1fe64962b518897f8959fae2c177" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" dependencies = [ "libc", - "winapi", + "windows-targets 0.48.5", ] [[package]] @@ -529,7 +522,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -661,12 +654,12 @@ checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" [[package]] name = "libloading" -version = "0.7.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" dependencies = [ "cfg-if", - "winapi", + "windows-targets 0.52.4", ] [[package]] @@ -767,9 +760,9 @@ dependencies = [ [[package]] name = "napi" -version = "2.13.3" +version = "2.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd063c93b900149304e3ba96ce5bf210cd4f81ef5eb80ded0d100df3e85a3ac0" +checksum = "54a63d0570e4c3e0daf7a8d380563610e159f538e20448d6c911337246f40e84" dependencies = [ "bitflags 2.4.1", "ctor", @@ -781,29 +774,29 @@ dependencies = [ [[package]] name = "napi-build" -version = "2.0.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882a73d9ef23e8dc2ebbffb6a6ae2ef467c0f18ac10711e4cc59c5485d41df0e" +checksum = "2f9130fccc5f763cf2069b34a089a18f0d0883c66aceb81f2fad541a3d823c43" [[package]] name = "napi-derive" -version = "2.13.0" +version = "2.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da1c6a8fa84d549aa8708fcd062372bf8ec6e849de39016ab921067d21bde367" +checksum = "05bb7c37e3c1dda9312fdbe4a9fc7507fca72288ba154ec093e2d49114e727ce" dependencies = [ "cfg-if", "convert_case", "napi-derive-backend", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.38", ] [[package]] name = "napi-derive-backend" -version = "1.0.52" +version = "1.0.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20bbc7c69168d06a848f925ec5f0e0997f98e8c8d4f2cc30157f0da51c009e17" +checksum = "f785a8b8d7b83e925f5aa6d2ae3c159d17fe137ac368dc185bef410e7acdaeb4" dependencies = [ "convert_case", "once_cell", @@ -811,14 +804,14 @@ dependencies = [ "quote", "regex", "semver", - "syn 1.0.109", + "syn 2.0.38", ] [[package]] name = "napi-sys" -version = "2.2.3" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "166b5ef52a3ab5575047a9fe8d4a030cdd0f63c96f071cd6907674453b07bae3" +checksum = "2503fa6af34dc83fb74888df8b22afe933b58d37daf7d80424b1c60c68196b8b" dependencies = [ "libloading", ] @@ -896,9 +889,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "os_pipe" @@ -1201,12 +1194,6 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" -[[package]] -name = "str-buf" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" - [[package]] name = "syn" version = "1.0.109" @@ -1506,15 +1493,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "winapi-wsapoll" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c17110f57155602a80dca10be03852116403c9ff3cd25b079d666f2aa3df6e" -dependencies = [ - "winapi", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1704,22 +1682,17 @@ dependencies = [ [[package]] name = "x11rb" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1641b26d4dec61337c35a1b1aaf9e3cba8f46f0b43636c609ab0291a648040a" +checksum = "f8f25ead8c7e4cba123243a6367da5d3990e0d3affa708ea19dce96356bd9f1a" dependencies = [ "gethostname", - "nix", - "winapi", - "winapi-wsapoll", + "rustix", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d6c3f9a0fb6701fab8f6cea9b0c0bd5d6876f1f89f7fada07e558077c344bc" -dependencies = [ - "nix", -] +checksum = "e63e71c4b8bd9ffec2c963173a4dc4cbde9ee96961d4fcb4429db9929b606c34" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 48536934eea..a1625020e54 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -15,11 +15,11 @@ manual_test = [] [dependencies] aes = "=0.8.4" anyhow = "=1.0.80" -arboard = { version = "=3.3.0", default-features = false, features = ["wayland-data-control"] } +arboard = { version = "=3.3.2", default-features = false, features = ["wayland-data-control"] } base64 = "=0.22.0" cbc = { version = "=0.1.2", features = ["alloc"] } -napi = { version = "=2.13.3", features = ["async"] } -napi-derive = "=2.13.0" +napi = { version = "=2.16.0", features = ["async"] } +napi-derive = "=2.16.0" rand = "=0.8.5" retry = "=2.0.0" scopeguard = "=1.2.0" @@ -28,7 +28,7 @@ thiserror = "=1.0.51" typenum = "=1.17.0" [build-dependencies] -napi-build = "=2.0.1" +napi-build = "=2.1.2" [target.'cfg(windows)'.dependencies] widestring = "=1.0.2" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6b01918d6bd..52dd0fafdb2 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.3.1", + "version": "2024.3.2", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 60aa2ebae84..a613328878d 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -42,7 +42,6 @@ export class SettingsComponent implements OnInit { themeOptions: any[]; clearClipboardOptions: any[]; supportsBiometric: boolean; - additionalBiometricSettingsText: string; showAlwaysShowDock = false; requireEnableTray = false; showDuckDuckGoIntegrationOption = false; @@ -283,10 +282,6 @@ export class SettingsComponent implements OnInit { this.showMinToTray = this.platformUtilsService.getDevice() !== DeviceType.LinuxDesktop; this.showAlwaysShowDock = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop; this.supportsBiometric = await this.platformUtilsService.supportsBiometric(); - this.additionalBiometricSettingsText = - this.biometricText === "unlockWithTouchId" - ? "additionalTouchIdSettings" - : "additionalWindowsHelloSettings"; this.previousVaultTimeout = this.form.value.vaultTimeout; this.refreshTimeoutSettings$ @@ -700,4 +695,15 @@ export class SettingsComponent implements OnInit { throw new Error("Unsupported platform"); } } + + get additionalBiometricSettingsText() { + switch (this.platformUtilsService.getDevice()) { + case DeviceType.MacOsDesktop: + return "additionalTouchIdSettings"; + case DeviceType.WindowsDesktop: + return "additionalWindowsHelloSettings"; + default: + throw new Error("Unsupported platform"); + } + } } diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index fa396ab313b..4e74135c498 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -31,7 +31,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -147,7 +147,7 @@ export class AppComponent implements OnInit, OnDestroy { private modalService: ModalService, private keyConnectorService: KeyConnectorService, private userVerificationService: UserVerificationService, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private dialogService: DialogService, private biometricStateService: BiometricStateService, private stateEventRunnerService: StateEventRunnerService, @@ -265,7 +265,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 this.updateAppMenu(); - this.configService.triggerServerConfigFetch(); + await this.configService.ensureConfigFetched(); } break; case "openSettings": @@ -584,7 +584,6 @@ export class AppComponent implements OnInit, OnDestroy { await this.passwordGenerationService.clear(userBeingLoggedOut); await this.vaultTimeoutSettingsService.clear(userBeingLoggedOut); await this.policyService.clear(userBeingLoggedOut); - await this.keyConnectorService.clear(); await this.biometricStateService.logout(userBeingLoggedOut as UserId); await this.providerService.save(null, userBeingLoggedOut as UserId); diff --git a/apps/desktop/src/app/layout/account-switcher.component.ts b/apps/desktop/src/app/layout/account-switcher.component.ts index 499300086db..4e39ab00292 100644 --- a/apps/desktop/src/app/layout/account-switcher.component.ts +++ b/apps/desktop/src/app/layout/account-switcher.component.ts @@ -4,6 +4,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; import { concatMap, firstValueFrom, Subject, takeUntil } from "rxjs"; +import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; @@ -91,6 +92,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { private router: Router, private tokenService: TokenService, private environmentService: EnvironmentService, + private loginEmailService: LoginEmailServiceAbstraction, ) {} async ngOnInit(): Promise { @@ -137,7 +139,10 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { async addAccount() { this.close(); - await this.stateService.setRememberedEmail(null); + + this.loginEmailService.setRememberEmail(false); + await this.loginEmailService.saveEmailSettings(); + await this.router.navigate(["/login"]); await this.stateService.setActiveUser(null); } diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index f45d530eddf..d1a83d468c1 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -11,7 +11,6 @@ import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt. import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; -import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; @@ -36,7 +35,6 @@ export class InitService { private nativeMessagingService: NativeMessagingService, private themingService: AbstractThemingService, private encryptService: EncryptService, - private configService: ConfigService, @Inject(DOCUMENT) private document: Document, ) {} @@ -55,23 +53,9 @@ export class InitService { const htmlEl = this.win.document.documentElement; htmlEl.classList.add("os_" + this.platformUtilsService.getDeviceString()); this.themingService.applyThemeChangesTo(this.document); - let installAction = null; - const installedVersion = await this.stateService.getInstalledVersion(); - const currentVersion = await this.platformUtilsService.getApplicationVersion(); - if (installedVersion == null) { - installAction = "install"; - } else if (installedVersion !== currentVersion) { - installAction = "update"; - } - - if (installAction != null) { - await this.stateService.setInstalledVersion(currentVersion); - } const containerService = new ContainerService(this.cryptoService, this.encryptService); containerService.attachToGlobal(this.win); - - this.configService.init(); }; } } diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 495d6abcf15..84932ce7d95 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -1,8 +1,8 @@ -import { APP_INITIALIZER, InjectionToken, NgModule } from "@angular/core"; +import { APP_INITIALIZER, NgModule } from "@angular/core"; +import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SECURE_STORAGE, - STATE_FACTORY, STATE_SERVICE_USE_CACHE, LOCALES_DIRECTORY, SYSTEM_LANGUAGE, @@ -12,15 +12,15 @@ import { WINDOW, SUPPORTS_SECURE_STORAGE, SYSTEM_THEME_OBSERVABLE, + SafeInjectionToken, + STATE_FACTORY, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; -import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; -import { LoginService } from "@bitwarden/common/auth/services/login.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -77,153 +77,182 @@ import { DesktopFileDownloadService } from "./desktop-file-download.service"; import { InitService } from "./init.service"; import { RendererCryptoFunctionService } from "./renderer-crypto-function.service"; -const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); +const RELOAD_CALLBACK = new SafeInjectionToken<() => any>("RELOAD_CALLBACK"); + +// Desktop has its own Account definition which must be used in its StateService +const DESKTOP_STATE_FACTORY = new SafeInjectionToken>( + "DESKTOP_STATE_FACTORY", +); + +/** + * Provider definitions used in the ngModule. + * Add your provider definition here using the safeProvider function as a wrapper. This will give you type safety. + * If you need help please ask for it, do NOT change the type of this array. + */ +const safeProviders: SafeProvider[] = [ + safeProvider(InitService), + safeProvider(NativeMessagingService), + safeProvider(SearchBarService), + safeProvider(LoginGuard), + safeProvider(DialogService), + safeProvider({ + provide: APP_INITIALIZER as SafeInjectionToken<() => void>, + useFactory: (initService: InitService) => initService.init(), + deps: [InitService], + multi: true, + }), + safeProvider({ + provide: DESKTOP_STATE_FACTORY, + useValue: new StateFactory(GlobalState, Account), + }), + safeProvider({ + provide: STATE_FACTORY, + useValue: null, + }), + safeProvider({ + provide: RELOAD_CALLBACK, + useValue: null, + }), + safeProvider({ + provide: LogServiceAbstraction, + useClass: ElectronLogRendererService, + deps: [], + }), + safeProvider({ + provide: PlatformUtilsServiceAbstraction, + useClass: ElectronPlatformUtilsService, + deps: [I18nServiceAbstraction, MessagingServiceAbstraction], + }), + safeProvider({ + // We manually override the value of SUPPORTS_SECURE_STORAGE here to avoid + // the TokenService having to inject the PlatformUtilsService which introduces a + // circular dependency on Desktop only. + provide: SUPPORTS_SECURE_STORAGE, + useValue: ELECTRON_SUPPORTS_SECURE_STORAGE, + }), + safeProvider({ + provide: I18nServiceAbstraction, + useClass: I18nRendererService, + deps: [SYSTEM_LANGUAGE, LOCALES_DIRECTORY, GlobalStateProvider], + }), + safeProvider({ + provide: MessagingServiceAbstraction, + useClass: ElectronRendererMessagingService, + deps: [BroadcasterServiceAbstraction], + }), + safeProvider({ + provide: AbstractStorageService, + useClass: ElectronRendererStorageService, + deps: [], + }), + safeProvider({ + provide: SECURE_STORAGE, + useClass: ElectronRendererSecureStorageService, + deps: [], + }), + safeProvider({ provide: MEMORY_STORAGE, useClass: MemoryStorageService, deps: [] }), + safeProvider({ + provide: OBSERVABLE_MEMORY_STORAGE, + useClass: MemoryStorageServiceForStateProviders, + deps: [], + }), + safeProvider({ provide: OBSERVABLE_DISK_STORAGE, useExisting: AbstractStorageService }), + safeProvider({ + provide: SystemServiceAbstraction, + useClass: SystemService, + deps: [ + MessagingServiceAbstraction, + PlatformUtilsServiceAbstraction, + RELOAD_CALLBACK, + StateServiceAbstraction, + AutofillSettingsServiceAbstraction, + VaultTimeoutSettingsService, + BiometricStateService, + ], + }), + safeProvider({ + provide: StateServiceAbstraction, + useClass: ElectronStateService, + deps: [ + AbstractStorageService, + SECURE_STORAGE, + MEMORY_STORAGE, + LogService, + DESKTOP_STATE_FACTORY, + AccountServiceAbstraction, + EnvironmentService, + TokenService, + MigrationRunner, + STATE_SERVICE_USE_CACHE, + ], + }), + safeProvider({ + provide: FileDownloadService, + useClass: DesktopFileDownloadService, + deps: [], + }), + safeProvider({ + provide: SYSTEM_THEME_OBSERVABLE, + useFactory: () => fromIpcSystemTheme(), + deps: [], + }), + safeProvider({ + provide: EncryptedMessageHandlerService, + deps: [ + StateServiceAbstraction, + AuthServiceAbstraction, + CipherServiceAbstraction, + PolicyServiceAbstraction, + MessagingServiceAbstraction, + PasswordGenerationServiceAbstraction, + ], + }), + safeProvider({ + provide: NativeMessageHandlerService, + deps: [ + StateServiceAbstraction, + CryptoServiceAbstraction, + CryptoFunctionServiceAbstraction, + MessagingServiceAbstraction, + EncryptedMessageHandlerService, + DialogService, + DesktopAutofillSettingsService, + ], + }), + safeProvider({ + provide: CryptoFunctionServiceAbstraction, + useClass: RendererCryptoFunctionService, + deps: [WINDOW], + }), + safeProvider({ + provide: CryptoServiceAbstraction, + useClass: ElectronCryptoService, + deps: [ + KeyGenerationServiceAbstraction, + CryptoFunctionServiceAbstraction, + EncryptService, + PlatformUtilsServiceAbstraction, + LogService, + StateServiceAbstraction, + AccountServiceAbstraction, + StateProvider, + BiometricStateService, + ], + }), + safeProvider({ + provide: DesktopSettingsService, + deps: [StateProvider], + }), + safeProvider({ + provide: DesktopAutofillSettingsService, + deps: [StateProvider], + }), +]; @NgModule({ imports: [JslibServicesModule], declarations: [], - providers: [ - InitService, - NativeMessagingService, - SearchBarService, - LoginGuard, - DialogService, - { - provide: APP_INITIALIZER, - useFactory: (initService: InitService) => initService.init(), - deps: [InitService], - multi: true, - }, - { - provide: STATE_FACTORY, - useValue: new StateFactory(GlobalState, Account), - }, - { - provide: RELOAD_CALLBACK, - useValue: null, - }, - { provide: LogServiceAbstraction, useClass: ElectronLogRendererService, deps: [] }, - { - provide: PlatformUtilsServiceAbstraction, - useClass: ElectronPlatformUtilsService, - deps: [I18nServiceAbstraction, MessagingServiceAbstraction], - }, - { - // We manually override the value of SUPPORTS_SECURE_STORAGE here to avoid - // the TokenService having to inject the PlatformUtilsService which introduces a - // circular dependency on Desktop only. - provide: SUPPORTS_SECURE_STORAGE, - useValue: ELECTRON_SUPPORTS_SECURE_STORAGE, - }, - { - provide: I18nServiceAbstraction, - useClass: I18nRendererService, - deps: [SYSTEM_LANGUAGE, LOCALES_DIRECTORY, GlobalStateProvider], - }, - { - provide: MessagingServiceAbstraction, - useClass: ElectronRendererMessagingService, - deps: [BroadcasterServiceAbstraction], - }, - { provide: AbstractStorageService, useClass: ElectronRendererStorageService }, - { provide: SECURE_STORAGE, useClass: ElectronRendererSecureStorageService }, - { provide: MEMORY_STORAGE, useClass: MemoryStorageService }, - { provide: OBSERVABLE_MEMORY_STORAGE, useClass: MemoryStorageServiceForStateProviders }, - { provide: OBSERVABLE_DISK_STORAGE, useExisting: AbstractStorageService }, - { - provide: SystemServiceAbstraction, - useClass: SystemService, - deps: [ - MessagingServiceAbstraction, - PlatformUtilsServiceAbstraction, - RELOAD_CALLBACK, - StateServiceAbstraction, - AutofillSettingsServiceAbstraction, - VaultTimeoutSettingsService, - BiometricStateService, - ], - }, - { - provide: StateServiceAbstraction, - useClass: ElectronStateService, - deps: [ - AbstractStorageService, - SECURE_STORAGE, - MEMORY_STORAGE, - LogService, - STATE_FACTORY, - AccountServiceAbstraction, - EnvironmentService, - TokenService, - MigrationRunner, - STATE_SERVICE_USE_CACHE, - ], - }, - { - provide: FileDownloadService, - useClass: DesktopFileDownloadService, - }, - { - provide: SYSTEM_THEME_OBSERVABLE, - useFactory: () => fromIpcSystemTheme(), - }, - { - provide: EncryptedMessageHandlerService, - deps: [ - StateServiceAbstraction, - AuthServiceAbstraction, - CipherServiceAbstraction, - PolicyServiceAbstraction, - MessagingServiceAbstraction, - PasswordGenerationServiceAbstraction, - ], - }, - { - provide: NativeMessageHandlerService, - deps: [ - StateServiceAbstraction, - CryptoServiceAbstraction, - CryptoFunctionServiceAbstraction, - MessagingServiceAbstraction, - EncryptedMessageHandlerService, - DialogService, - ], - }, - { - provide: LoginServiceAbstraction, - useClass: LoginService, - deps: [StateServiceAbstraction], - }, - { - provide: CryptoFunctionServiceAbstraction, - useClass: RendererCryptoFunctionService, - deps: [WINDOW], - }, - { - provide: CryptoServiceAbstraction, - useClass: ElectronCryptoService, - deps: [ - KeyGenerationServiceAbstraction, - CryptoFunctionServiceAbstraction, - EncryptService, - PlatformUtilsServiceAbstraction, - LogService, - StateServiceAbstraction, - AccountServiceAbstraction, - StateProvider, - BiometricStateService, - ], - }, - { - provide: DesktopSettingsService, - useClass: DesktopSettingsService, - deps: [StateProvider], - }, - { - provide: DesktopAutofillSettingsService, - useClass: DesktopAutofillSettingsService, - deps: [StateProvider], - }, - ], + // Do not register your dependency here! Add it to the typesafeProviders array using the helper function + providers: safeProviders, }) export class ServicesModule {} diff --git a/apps/desktop/src/app/tools/export/export.component.ts b/apps/desktop/src/app/tools/export/export.component.ts index 3a740122ebe..80ae3c80f96 100644 --- a/apps/desktop/src/app/tools/export/export.component.ts +++ b/apps/desktop/src/app/tools/export/export.component.ts @@ -1,7 +1,6 @@ import { Component, OnInit } from "@angular/core"; import { UntypedFormBuilder } from "@angular/forms"; -import { ExportComponent as BaseExportComponent } from "@bitwarden/angular/tools/export/components/export.component"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -12,6 +11,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; +import { ExportComponent as BaseExportComponent } from "@bitwarden/vault-export-ui"; @Component({ selector: "app-export", diff --git a/apps/desktop/src/auth/hint.component.ts b/apps/desktop/src/auth/hint.component.ts index 5eeeb8106e9..cee1f189817 100644 --- a/apps/desktop/src/auth/hint.component.ts +++ b/apps/desktop/src/auth/hint.component.ts @@ -2,8 +2,8 @@ import { Component } from "@angular/core"; import { Router } from "@angular/router"; import { HintComponent as BaseHintComponent } from "@bitwarden/angular/auth/components/hint.component"; +import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -19,8 +19,8 @@ export class HintComponent extends BaseHintComponent { i18nService: I18nService, apiService: ApiService, logService: LogService, - loginService: LoginService, + loginEmailService: LoginEmailServiceAbstraction, ) { - super(router, i18nService, apiService, platformUtilsService, logService, loginService); + super(router, i18nService, apiService, platformUtilsService, logService, loginEmailService); } } diff --git a/apps/desktop/src/auth/lock.component.spec.ts b/apps/desktop/src/auth/lock.component.spec.ts index 6ecf93deb84..0339889bf75 100644 --- a/apps/desktop/src/auth/lock.component.spec.ts +++ b/apps/desktop/src/auth/lock.component.spec.ts @@ -12,6 +12,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -23,7 +24,10 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; import { LockComponent } from "./lock.component"; @@ -49,6 +53,9 @@ describe("LockComponent", () => { let platformUtilsServiceMock: MockProxy; let activatedRouteMock: MockProxy; + const mockUserId = Utils.newGuid() as UserId; + const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); + beforeEach(async () => { stateServiceMock = mock(); stateServiceMock.activeAccount$ = of(null); @@ -147,6 +154,10 @@ describe("LockComponent", () => { provide: BiometricStateService, useValue: biometricStateService, }, + { + provide: AccountService, + useValue: accountService, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/apps/desktop/src/auth/lock.component.ts b/apps/desktop/src/auth/lock.component.ts index 7403f7481d2..8b1448c06fc 100644 --- a/apps/desktop/src/auth/lock.component.ts +++ b/apps/desktop/src/auth/lock.component.ts @@ -9,6 +9,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { DeviceType } from "@bitwarden/common/enums"; @@ -59,6 +60,7 @@ export class LockComponent extends BaseLockComponent { userVerificationService: UserVerificationService, pinCryptoService: PinCryptoServiceAbstraction, biometricStateService: BiometricStateService, + accountService: AccountService, ) { super( router, @@ -81,6 +83,7 @@ export class LockComponent extends BaseLockComponent { userVerificationService, pinCryptoService, biometricStateService, + accountService, ); } diff --git a/apps/desktop/src/auth/login/login-via-auth-request.component.ts b/apps/desktop/src/auth/login/login-via-auth-request.component.ts index b4242c36fba..0a339030ba2 100644 --- a/apps/desktop/src/auth/login/login-via-auth-request.component.ts +++ b/apps/desktop/src/auth/login/login-via-auth-request.component.ts @@ -1,5 +1,5 @@ import { Location } from "@angular/common"; -import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { Component, ViewChild, ViewContainerRef } from "@angular/core"; import { Router } from "@angular/router"; import { LoginViaAuthRequestComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/auth/components/login-via-auth-request.component"; @@ -7,12 +7,13 @@ import { ModalService } from "@bitwarden/angular/services/modal.service"; import { AuthRequestServiceAbstraction, LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -31,10 +32,7 @@ import { EnvironmentComponent } from "../environment.component"; selector: "app-login-via-auth-request", templateUrl: "login-via-auth-request.component.html", }) -export class LoginViaAuthRequestComponent - extends BaseLoginWithDeviceComponent - implements OnInit, OnDestroy -{ +export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent { @ViewChild("environment", { read: ViewContainerRef, static: true }) environmentModal: ViewContainerRef; showingModal = false; @@ -56,10 +54,11 @@ export class LoginViaAuthRequestComponent private modalService: ModalService, syncService: SyncService, stateService: StateService, - loginService: LoginService, + loginEmailService: LoginEmailServiceAbstraction, deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, authRequestService: AuthRequestServiceAbstraction, loginStrategyService: LoginStrategyServiceAbstraction, + accountService: AccountService, private location: Location, ) { super( @@ -77,10 +76,11 @@ export class LoginViaAuthRequestComponent anonymousHubService, validationService, stateService, - loginService, + loginEmailService, deviceTrustCryptoService, authRequestService, loginStrategyService, + accountService, ); super.onSuccessfulLogin = () => { @@ -109,10 +109,6 @@ export class LoginViaAuthRequestComponent }); } - ngOnDestroy(): void { - super.ngOnDestroy(); - } - back() { this.location.back(); } diff --git a/apps/desktop/src/auth/login/login.component.html b/apps/desktop/src/auth/login/login.component.html index 06ee5db32dc..eef0580d4e1 100644 --- a/apps/desktop/src/auth/login/login.component.html +++ b/apps/desktop/src/auth/login/login.component.html @@ -99,7 +99,7 @@ class="btn block" type="button" routerLink="/accessibility-cookie" - (click)="setFormValues()" + (click)="setLoginEmailValues()" > {{ "loadAccessibilityCookie" | i18n }} @@ -139,7 +139,7 @@ type="button" class="text text-primary password-hint-btn" routerLink="/hint" - (click)="setFormValues()" + (click)="setLoginEmailValues()" > {{ "getMasterPasswordHint" | i18n }} diff --git a/apps/desktop/src/auth/login/login.component.ts b/apps/desktop/src/auth/login/login.component.ts index dd22a0fa373..eb7b9243624 100644 --- a/apps/desktop/src/auth/login/login.component.ts +++ b/apps/desktop/src/auth/login/login.component.ts @@ -7,9 +7,11 @@ import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/auth/components/login.component"; import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common"; +import { + LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, +} from "@bitwarden/auth/common"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -69,7 +71,7 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy { formBuilder: FormBuilder, formValidationErrorService: FormValidationErrorsService, route: ActivatedRoute, - loginService: LoginService, + loginEmailService: LoginEmailServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction, webAuthnLoginService: WebAuthnLoginServiceAbstraction, ) { @@ -89,7 +91,7 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy { formBuilder, formValidationErrorService, route, - loginService, + loginEmailService, ssoLoginService, webAuthnLoginService, ); diff --git a/apps/desktop/src/auth/sso.component.ts b/apps/desktop/src/auth/sso.component.ts index 0268133192f..210319b9ed2 100644 --- a/apps/desktop/src/auth/sso.component.ts +++ b/apps/desktop/src/auth/sso.component.ts @@ -8,7 +8,7 @@ import { } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -38,7 +38,7 @@ export class SsoComponent extends BaseSsoComponent { passwordGenerationService: PasswordGenerationServiceAbstraction, logService: LogService, userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - configService: ConfigServiceAbstraction, + configService: ConfigService, ) { super( ssoLoginService, diff --git a/apps/desktop/src/auth/two-factor.component.ts b/apps/desktop/src/auth/two-factor.component.ts index 9b862e7c9f5..fdbc52b4bf4 100644 --- a/apps/desktop/src/auth/two-factor.component.ts +++ b/apps/desktop/src/auth/two-factor.component.ts @@ -7,16 +7,16 @@ import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -56,10 +56,10 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { logService: LogService, twoFactorService: TwoFactorService, appIdService: AppIdService, - loginService: LoginService, + loginEmailService: LoginEmailServiceAbstraction, userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction, - configService: ConfigServiceAbstraction, + configService: ConfigService, @Inject(WINDOW) protected win: Window, ) { super( @@ -75,7 +75,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { logService, twoFactorService, appIdService, - loginService, + loginEmailService, userDecryptionOptionsService, ssoLoginService, configService, diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index 1235230dd39..b1deba9dd9e 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index d7c405159fd..b95501bbfd4 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "تنسيقات مشتركة", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index 18f150745bb..a18d752620d 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Ortaq formatlar", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index 675f9cd0f28..0529c407a49 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index 030dc45683f..3217f167edb 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Често използвани формати", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Отстраняване на проблеми" + }, + "disableHardwareAccelerationRestart": { + "message": "Изключете хардуерното ускорение и рестартирайте" + }, + "enableHardwareAccelerationRestart": { + "message": "Включете хардуерното ускорение и рестартирайте" } } diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index debefc0e65c..9599fc68275 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index 4c6f2b87449..6e6ca99bac0 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index f4ef70c2ebb..3cdedd0274c 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -1645,10 +1645,10 @@ "message": "Habilita una capa de seguretat addicional mitjançant la validació de frases d'empremtes dactilars quan estableix un enllaç entre l'escriptori i el navegador. Quan està habilitat, requereix la intervenció i verificació de l'usuari cada vegada que s'estableix una connexió." }, "enableHardwareAcceleration": { - "message": "Use hardware acceleration" + "message": "Utilitza l'acceleració de maquinari" }, "enableHardwareAccelerationDesc": { - "message": "By default this setting is ON. Turn OFF only if you experience graphical issues. Restart is required." + "message": "Per defecte, aquesta configuració està activada. Apagueu només si teniu problemes gràfics. Cal reiniciar." }, "approve": { "message": "Aprova" @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Formats comuns", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Resolució de problemes" + }, + "disableHardwareAccelerationRestart": { + "message": "Desactiveu l'acceleració de maquinari i reinicieu" + }, + "enableHardwareAccelerationRestart": { + "message": "Activeu l'acceleració i reinicieu el maquinari" } } diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index 534438aff26..efa1dccdc12 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Společné formáty", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Řešení problémů" + }, + "disableHardwareAccelerationRestart": { + "message": "Zakázat hardwarovou akceleraci a restartovat" + }, + "enableHardwareAccelerationRestart": { + "message": "Povolit hardwarovou akceleraci a restartovat" } } diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index e266886a90e..65ce77b3400 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index 795c1cc272a..0da6c705ca4 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -545,7 +545,7 @@ "message": "Angivelse af hovedadgangskode igen er obligatorisk." }, "masterPasswordMinlength": { - "message": "Master password must be at least $VALUE$ characters long.", + "message": "Hovedadgangskoden skal udgøre minimum $VALUE$ tegn.", "description": "The Master Password must be at least a specific number of characters long.", "placeholders": { "value": { @@ -780,7 +780,7 @@ "message": "Kontakt os" }, "helpAndFeedback": { - "message": "Help and feedback" + "message": "Hjælp og feedback" }, "getHelp": { "message": "Få hjælp" @@ -1420,10 +1420,10 @@ "message": "oplås din boks" }, "autoPromptWindowsHello": { - "message": "Bed om Windows Hello ved start" + "message": "Anmod om Windows Hello ved app-start" }, "autoPromptTouchId": { - "message": "Bed om Touch ID ved start" + "message": "Anmod om Touch ID ved app-start" }, "requirePasswordOnStart": { "message": "Kræv adgangskode eller PIN-kode ved app-start" @@ -1645,10 +1645,10 @@ "message": "Tilføj et ekstra sikkerhedslag ved at kræve bekræftelse af fingeraftrykssætning, når der oprettes forbindelse mellem din computer og din browser. Dette kræver brugerhandling og bekræftelse, hver gang der forbindelse oprettes." }, "enableHardwareAcceleration": { - "message": "Use hardware acceleration" + "message": "Benyt hardwareacceleration" }, "enableHardwareAccelerationDesc": { - "message": "By default this setting is ON. Turn OFF only if you experience graphical issues. Restart is required." + "message": "Denne indstilling er som standard TIL. Slå kun FRA, hvis der opleves grafiske problemer. Genstart kræves." }, "approve": { "message": "Godkend" @@ -1931,7 +1931,7 @@ "message": "Minutter" }, "vaultTimeoutPolicyInEffect": { - "message": "Organisationspolitikker påvirker din boks-timeout. Maksimalt tilladt boks-timeout er $HOURS$ time(r) og $MINUTES$ minut(ter)", + "message": "Organisationspolitikkerne har fastsat den maksimalt tilladte boks-timeout til $HOURS$ time(r) og $MINUTES$ minut(ter).", "placeholders": { "hours": { "content": "$1", @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Almindelige formater", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Fejlfinding" + }, + "disableHardwareAccelerationRestart": { + "message": "Deaktivér hardwareacceleration og genstart" + }, + "enableHardwareAccelerationRestart": { + "message": "Aktivér hardwareacceleration og genstart" } } diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 4132caa7ad7..bacf1580233 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Gängigste Formate", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Problembehandlung" + }, + "disableHardwareAccelerationRestart": { + "message": "Hardwarebeschleunigung deaktivieren und neu starten" + }, + "enableHardwareAccelerationRestart": { + "message": "Hardwarebeschleunigung aktivieren und neu starten" } } diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index 28e2f9f870f..f5e18bdb85d 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -404,7 +404,7 @@ "message": "Μήκος" }, "passwordMinLength": { - "message": "Minimum password length" + "message": "Ελάχιστο μήκος κωδικού" }, "uppercase": { "message": "Κεφαλαία (A-Z)" @@ -479,7 +479,7 @@ "message": "Το μέγιστο μέγεθος αρχείου είναι 500 MB." }, "encryptionKeyMigrationRequired": { - "message": "Encryption key migration required. Please login through the web vault to update your encryption key." + "message": "Απαιτείται μεταφορά κλειδιού κρυπτογράφησης. Παρακαλούμε συνδεθείτε μέσω του διαδικτυακής κρύπτης για να ενημερώσετε το κλειδί κρυπτογράφησης." }, "editedFolder": { "message": "Ο φάκελος αποθηκεύτηκε" @@ -561,10 +561,10 @@ "message": "Ο λογαριασμός σας έχει δημιουργηθεί! Τώρα μπορείτε να συνδεθείτε." }, "youSuccessfullyLoggedIn": { - "message": "You successfully logged in" + "message": "Έχετε συνδεθεί επιτυχώς" }, "youMayCloseThisWindow": { - "message": "You may close this window" + "message": "Μπορείτε να κλείσετε αυτό το παράθυρο" }, "masterPassSent": { "message": "Σας στείλαμε ένα email με την υπόδειξη του κύριου κωδικού." @@ -1087,7 +1087,7 @@ "message": "1 GB κρυπτογραφημένο αποθηκευτικό χώρο για συνημμένα αρχεία." }, "premiumSignUpTwoStepOptions": { - "message": "Proprietary two-step login options such as YubiKey and Duo." + "message": "Ιδιόκτητες επιλογές σύνδεσης δύο βημάτων, όπως το YubiKey και το Duo." }, "premiumSignUpReports": { "message": "Ασφάλεια κωδικών, υγιής λογαριασμός και αναφορές παραβίασης δεδομένων για να διατηρήσετε ασφαλή τη λίστα σας." @@ -1399,7 +1399,7 @@ "message": "Μη έγκυρος κωδικός PIN." }, "tooManyInvalidPinEntryAttemptsLoggingOut": { - "message": "Too many invalid PIN entry attempts. Logging out." + "message": "Πάρα πολλές άκυρες απόπειρες εισαγωγής PIN. Γίνεται αποσύνδεση." }, "unlockWithWindowsHello": { "message": "Ξεκλειδώστε με το Windows Hello" @@ -1414,7 +1414,7 @@ "message": "Ξεκλείδωμα με Touch ID" }, "additionalTouchIdSettings": { - "message": "Additional Touch ID settings" + "message": "Πρόσθετες ρυθμίσεις Touch ID" }, "touchIdConsentMessage": { "message": "Ξεκλειδώστε το vault σας" @@ -1505,7 +1505,7 @@ "message": "Ένα αποσυνδεδεμένο vault απαιτεί να κάνετε ξανά έλεγχο ταυτότητας για να αποκτήσετε πρόσβαση σε αυτό." }, "unlockMethodNeededToChangeTimeoutActionDesc": { - "message": "Set up an unlock method to change your vault timeout action." + "message": "Ρυθμίστε μια μέθοδο ξεκλειδώματος για να αλλάξετε την ενέργεια χρονικού ορίου κρύπτης." }, "lock": { "message": "Κλείδωμα", @@ -1546,15 +1546,15 @@ "message": "Ορισμός Κύριου Κωδικού" }, "orgPermissionsUpdatedMustSetPassword": { - "message": "Your organization permissions were updated, requiring you to set a master password.", + "message": "Τα δικαιώματα του οργανισμού σας ενημερώθηκαν, απαιτώντας από εσάς να ορίσετε έναν κύριο κωδικό πρόσβασης.", "description": "Used as a card title description on the set password page to explain why the user is there" }, "orgRequiresYouToSetPassword": { - "message": "Your organization requires you to set a master password.", + "message": "Ο οργανισμός σας απαιτεί να ορίσετε έναν κύριο κωδικό πρόσβασης.", "description": "Used as a card title description on the set password page to explain why the user is there" }, "verificationRequired": { - "message": "Verification required", + "message": "Απαιτείται επαλήθευση", "description": "Default title for the user verification dialog." }, "currentMasterPass": { @@ -1645,10 +1645,10 @@ "message": "Ενεργοποιήστε ένα πρόσθετο επίπεδο ασφάλειας απαιτώντας επικύρωση φράσης δακτυλικών αποτυπωμάτων κατά τη δημιουργία μιας σύνδεσης μεταξύ της επιφάνειας εργασίας σας και του προγράμματος περιήγησης. Όταν ενεργοποιηθεί, αυτό απαιτεί παρέμβαση χρήστη και επαλήθευση κάθε φορά που δημιουργείται σύνδεση." }, "enableHardwareAcceleration": { - "message": "Use hardware acceleration" + "message": "Χρήση επιτάχυνσης υλικού" }, "enableHardwareAccelerationDesc": { - "message": "By default this setting is ON. Turn OFF only if you experience graphical issues. Restart is required." + "message": "Εξ ορισμού αυτή η ρύθμιση είναι ΕΝΕΡΓΗ. Απενεργοποιήστε μόνο αν αντιμετωπίζετε γραφικά προβλήματα. Απαιτείται επανεκκίνηση." }, "approve": { "message": "Έγκριση" @@ -1690,7 +1690,7 @@ "message": "Μια πολιτική του οργανισμού, επηρεάζει τις επιλογές ιδιοκτησίας σας." }, "personalOwnershipPolicyInEffectImports": { - "message": "An organization policy has blocked importing items into your individual vault." + "message": "Μια πολιτική οργανισμού έχει αποτρέψει την εισαγωγή στοιχείων στην προσωπική κρύπτη σας." }, "allSends": { "message": "Όλα τα Sends", @@ -1886,43 +1886,43 @@ "message": "Ο Κύριος Κωδικός Πρόσβασής σας άλλαξε πρόσφατα από διαχειριστή στον οργανισμό σας. Για να αποκτήσετε πρόσβαση στο vault, πρέπει να τον ενημερώσετε τώρα. Η διαδικασία θα σας αποσυνδέσει από την τρέχουσα συνεδρία σας, απαιτώντας από εσάς να συνδεθείτε ξανά. Οι ενεργές συνεδρίες σε άλλες συσκευές ενδέχεται να συνεχίσουν να είναι ενεργές για μία ώρα." }, "updateWeakMasterPasswordWarning": { - "message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." + "message": "Ο κύριος κωδικός πρόσβασης δεν πληροί τις απαιτήσεις πολιτικής αυτού του οργανισμού. Για να έχετε πρόσβαση στην κρύπτη, πρέπει να ενημερώσετε τον κύριο κωδικό σας άμεσα. Η διαδικασία θα σάς αποσυνδέσει από την τρέχουσα συνεδρία σας, απαιτώντας να συνδεθείτε ξανά. Οι ενεργές συνεδρίες σε άλλες συσκευές ενδέχεται να συνεχίσουν να είναι ενεργές για το πολύ μία ώρα." }, "tryAgain": { - "message": "Try again" + "message": "Προσπαθήστε ξανά" }, "verificationRequiredForActionSetPinToContinue": { - "message": "Verification required for this action. Set a PIN to continue." + "message": "Απαιτείται επαλήθευση για αυτήν την ενέργεια. Ορίστε ένα PIN για να συνεχίσετε." }, "setPin": { - "message": "Set PIN" + "message": "Ορισμός PIN" }, "verifyWithBiometrics": { - "message": "Verify with biometrics" + "message": "Επαλήθευση με βιομετρικά" }, "awaitingConfirmation": { - "message": "Awaiting confirmation" + "message": "Σε αναμονή επιβεβαίωσης" }, "couldNotCompleteBiometrics": { - "message": "Could not complete biometrics." + "message": "Αδύνατη η ολοκλήρωση των βιομετρικών." }, "needADifferentMethod": { - "message": "Need a different method?" + "message": "Χρειάζεστε μια διαφορετική μέθοδο;" }, "useMasterPassword": { - "message": "Use master password" + "message": "Χρήση κύριου κωδικού" }, "usePin": { - "message": "Use PIN" + "message": "Χρήση PIN" }, "useBiometrics": { - "message": "Use biometrics" + "message": "Χρήση βιομετρικών" }, "enterVerificationCodeSentToEmail": { - "message": "Enter the verification code that was sent to your email." + "message": "Εισάγετε τον κωδικό επαλήθευσης που έχει σταλεί στο email σας." }, "resendCode": { - "message": "Resend code" + "message": "Επαναποστολή κωδικού" }, "hours": { "message": "Ώρες" @@ -1944,7 +1944,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is $HOURS$ hour(s) and $MINUTES$ minute(s). Your vault timeout action is set to $ACTION$.", + "message": "Οι πολιτικές του οργανισμού σας επηρεάζουν το χρονικό όριο λήξης της κρύ[της σας. Το μέγιστο επιτρεπόμενο χρονικό όριο λήξης vault είναι $HOURS$ ώρα(ες) και $MINUTES$ λεπτό(ά). H ενέργεια χρονικού ορίου λήξης της κρύπτης είναι ορισμένη ως $ACTION$.", "placeholders": { "hours": { "content": "$1", @@ -1961,7 +1961,7 @@ } }, "vaultTimeoutActionPolicyInEffect": { - "message": "Your organization policies have set your vault timeout action to $ACTION$.", + "message": "Οι πολιτικές του οργανισμού σας έχουν ορίσει την ενέργεια χρονικού ορίου λήξης κρύπτης σε $ACTION$.", "placeholders": { "action": { "content": "$1", @@ -2051,7 +2051,7 @@ "message": "Εξαγωγή Προσωπικού Vault" }, "exportingIndividualVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", + "message": "Μόνο τα μεμονωμένα αντικείμενα κρύπτης που σχετίζονται με το $EMAIL$ θα εξαχθούν. Τα αντικείμενα κρύπτης οργανισμού δε θα συμπεριληφθούν. Μόνο πληροφορίες αντικειμένων κρύπτης θα εξαχθούν και δε θα περιλαμβάνουν συσχετιζόμενα συνημμένα.", "placeholders": { "email": { "content": "$1", @@ -2176,7 +2176,7 @@ "message": "Συνδεθείτε με άλλη συσκευή" }, "loginInitiated": { - "message": "Login initiated" + "message": "Η σύνδεση ξεκίνησε" }, "notificationSentDevice": { "message": "Μια ειδοποίηση έχει σταλεί στη συσκευή σας." @@ -2310,7 +2310,7 @@ } }, "windowsBiometricUpdateWarning": { - "message": "Bitwarden recommends updating your biometric settings to require your master password (or PIN) on the first unlock. Would you like to update your settings now?" + "message": "Το Bitwarden συστήνει την ενημέρωση των βιομετρικών ρυθμίσεών σας ώστε να απαιτηθεί ο κύριος κωδικός πρόσβασης (ή PIN) στο πρώτο ξεκλείδωμα. Θέλετε να ενημερώσετε τις ρυθμίσεις σας τώρα;" }, "windowsBiometricUpdateWarningTitle": { "message": "Ενημέρωση Προτεινόμενων Ρυθμίσεων" @@ -2319,74 +2319,74 @@ "message": "Απαιτείται έγκριση συσκευής. Επιλέξτε μια επιλογή έγκρισης παρακάτω:" }, "rememberThisDevice": { - "message": "Remember this device" + "message": "Απομνημόνευση αυτής της συσκευής" }, "uncheckIfPublicDevice": { - "message": "Uncheck if using a public device" + "message": "Αποεπιλογή αν γίνεται χρήση δημόσιας συσκευής" }, "approveFromYourOtherDevice": { - "message": "Approve from your other device" + "message": "Έγκριση από άλλη συσκευή σας" }, "requestAdminApproval": { - "message": "Request admin approval" + "message": "Αίτηση έγκρισης διαχειριστή" }, "approveWithMasterPassword": { - "message": "Approve with master password" + "message": "Έγκριση με τον κύριο κωδικό" }, "region": { - "message": "Region" + "message": "Περιοχή" }, "ssoIdentifierRequired": { - "message": "Organization SSO identifier is required." + "message": "Απαιτείται αναγνωριστικό οργανισμού SSO." }, "eu": { - "message": "EU", + "message": "ΕΕ", "description": "European Union" }, "loggingInOn": { - "message": "Logging in on" + "message": "Σύνδεση σε" }, "selfHostedServer": { - "message": "self-hosted" + "message": "αυτο-φιλοξενούμενο" }, "accessDenied": { - "message": "Access denied. You do not have permission to view this page." + "message": "Δεν επιτρέπεται η πρόσβαση. Δεν έχετε άδεια για να δείτε αυτή τη σελίδα." }, "accountSuccessfullyCreated": { - "message": "Account successfully created!" + "message": "Επιτυχής δημιουργία λογαριασμού!" }, "adminApprovalRequested": { - "message": "Admin approval requested" + "message": "Ζητήθηκε έγκριση διαχειριστή" }, "adminApprovalRequestSentToAdmins": { - "message": "Your request has been sent to your admin." + "message": "Το αίτημά σας εστάλη στον διαχειριστή σας." }, "youWillBeNotifiedOnceApproved": { - "message": "You will be notified once approved." + "message": "Θα ειδοποιηθείτε μόλις εγκριθεί." }, "troubleLoggingIn": { - "message": "Trouble logging in?" + "message": "Πρόβλημα σύνδεσης;" }, "loginApproved": { - "message": "Login approved" + "message": "Η σύνδεση εγκρίθηκε" }, "userEmailMissing": { - "message": "User email missing" + "message": "Το email του χρήστη λείπει" }, "deviceTrusted": { - "message": "Device trusted" + "message": "Αξιόπιστη συσκευή" }, "inputRequired": { - "message": "Input is required." + "message": "Απαιτείται είσοδος." }, "required": { "message": "απαιτείται" }, "search": { - "message": "Search" + "message": "Αναζήτηση" }, "inputMinLength": { - "message": "Input must be at least $COUNT$ characters long.", + "message": "Η είσοδος πρέπει να είναι τουλάχιστον $COUNT$ χαρακτήρες.", "placeholders": { "count": { "content": "$1", @@ -2395,7 +2395,7 @@ } }, "inputMaxLength": { - "message": "Input must not exceed $COUNT$ characters in length.", + "message": "Η είσοδος δεν πρέπει να υπερβαίνει τους $COUNT$ χαρακτήρες.", "placeholders": { "count": { "content": "$1", @@ -2404,7 +2404,7 @@ } }, "inputForbiddenCharacters": { - "message": "The following characters are not allowed: $CHARACTERS$", + "message": "Οι ακόλουθοι χαρακτήρες δεν επιτρέπονται: $CHARACTERS$", "placeholders": { "characters": { "content": "$1", @@ -2413,7 +2413,7 @@ } }, "inputMinValue": { - "message": "Input value must be at least $MIN$.", + "message": "Η τιμή εισόδου πρέπει να είναι τουλάχιστον $MIN$.", "placeholders": { "min": { "content": "$1", @@ -2422,7 +2422,7 @@ } }, "inputMaxValue": { - "message": "Input value must not exceed $MAX$.", + "message": "Η τιμή εισόδου δεν πρέπει να υπερβαίνει το $MAX$.", "placeholders": { "max": { "content": "$1", @@ -2431,17 +2431,17 @@ } }, "multipleInputEmails": { - "message": "1 or more emails are invalid" + "message": "1 ή περισσότερα email δεν είναι έγκυρα" }, "inputTrimValidator": { - "message": "Input must not contain only whitespace.", + "message": "Η είσοδος δεν πρέπει να περιέχει μόνο κενά.", "description": "Notification to inform the user that a form's input can't contain only whitespace." }, "inputEmail": { - "message": "Input is not an email address." + "message": "Η είσοδος δεν είναι διεύθυνση email." }, "fieldsNeedAttention": { - "message": "$COUNT$ field(s) above need your attention.", + "message": "$COUNT$ πεδίο(α) παραπάνω χρειάζονται την προσοχή σας.", "placeholders": { "count": { "content": "$1", @@ -2450,22 +2450,22 @@ } }, "selectPlaceholder": { - "message": "-- Select --" + "message": "-- Επιλογή --" }, "multiSelectPlaceholder": { - "message": "-- Type to filter --" + "message": "-- Πληκτρολογήστε για φιλτράρισμα --" }, "multiSelectLoading": { - "message": "Retrieving options..." + "message": "Ανάκτηση επιλογών..." }, "multiSelectNotFound": { - "message": "No items found" + "message": "Δεν βρέθηκαν αντικείμενα" }, "multiSelectClearAll": { - "message": "Clear all" + "message": "Εκκαθάριση όλων" }, "plusNMore": { - "message": "+ $QUANTITY$ more", + "message": "+ $QUANTITY$ ακόμα", "placeholders": { "quantity": { "content": "$1", @@ -2474,44 +2474,44 @@ } }, "submenu": { - "message": "Submenu" + "message": "Υπομενού" }, "skipToContent": { - "message": "Skip to content" + "message": "Μετάβαση στο περιεχόμενο" }, "typePasskey": { - "message": "Passkey" + "message": "Κλειδί πρόσβασης" }, "passkeyNotCopied": { - "message": "Passkey will not be copied" + "message": "Το κλειδί πρόσβασης δεν θα αντιγραφεί" }, "passkeyNotCopiedAlert": { - "message": "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?" + "message": "Το κλειδί πρόσβασης δε θα αντιγραφεί στο κλωνοποιημένο στοιχείο. Θέλετε να συνεχίσετε την κλωνοποίηση αυτού του στοιχείου;" }, "aliasDomain": { - "message": "Alias domain" + "message": "Ψευδώνυμο τομέα" }, "importData": { - "message": "Import data", + "message": "Εισαγωγή δεδομένων", "description": "Used for the desktop menu item and the header of the import dialog" }, "importError": { - "message": "Import error" + "message": "Σφάλμα κατά την εισαγωγή" }, "importErrorDesc": { - "message": "There was a problem with the data you tried to import. Please resolve the errors listed below in your source file and try again." + "message": "Παρουσιάστηκε πρόβλημα με τα δεδομένα που επιχειρήσατε να εισαγάγετε. Παρακαλώ επιλύστε τα σφάλματα που αναφέρονται παρακάτω στο αρχείο πηγής και προσπαθήστε ξανά." }, "resolveTheErrorsBelowAndTryAgain": { - "message": "Resolve the errors below and try again." + "message": "Επιλύστε τα παρακάτω σφάλματα και προσπαθήστε ξανά." }, "description": { - "message": "Description" + "message": "Περιγραφή" }, "importSuccess": { - "message": "Data successfully imported" + "message": "Τα δεδομένα εισήχθησαν επιτυχώς" }, "importSuccessNumberOfItems": { - "message": "A total of $AMOUNT$ items were imported.", + "message": "Ένα σύνολο $AMOUNT$ στοιχείων εισήχθησαν.", "placeholders": { "amount": { "content": "$1", @@ -2520,10 +2520,10 @@ } }, "total": { - "message": "Total" + "message": "Σύνολο" }, "importWarning": { - "message": "You are importing data to $ORGANIZATION$. Your data may be shared with members of this organization. Do you want to proceed?", + "message": "Εισαγάγετε δεδομένα στην $ORGANIZATION$. Τα δεδομένα σας μπορεί να μοιραστούν με μέλη αυτού του οργανισμού. Θέλετε να συνεχίσετε;", "placeholders": { "organization": { "content": "$1", @@ -2532,22 +2532,22 @@ } }, "launchDuoAndFollowStepsToFinishLoggingIn": { - "message": "Launch Duo and follow the steps to finish logging in." + "message": "Εκκινήστε το Duo και ακολουθήστε τα βήματα για να ολοκληρώσετε τη σύνδεση." }, "duoRequiredByOrgForAccount": { - "message": "Duo two-step login is required for your account." + "message": "Η Σύνδεση δύο βημάτων Duo απαιτείται για τον λογαριασμό σας." }, "launchDuo": { - "message": "Launch Duo in Browser" + "message": "Εκκίνηση Duo στον περιηγητή" }, "importFormatError": { - "message": "Data is not formatted correctly. Please check your import file and try again." + "message": "Τα δεδομένα δεν έχουν διαμορφωθεί σωστά. Ελέγξτε το αρχείο εισαγωγής και δοκιμάστε ξανά." }, "importNothingError": { - "message": "Nothing was imported." + "message": "Τίποτα δεν εισήχθη." }, "importEncKeyError": { - "message": "Error decrypting the exported file. Your encryption key does not match the encryption key used export the data." + "message": "Σφάλμα αποκρυπτογράφησης του εξαγόμενου αρχείου. Το κλειδί κρυπτογράφησης δεν ταιριάζει με το κλειδί κρυπτογράφησης που χρησιμοποιήθηκε για την εξαγωγή των δεδομένων." }, "invalidFilePassword": { "message": "Invalid file password, please use the password you entered when you created the export file." @@ -2633,59 +2633,68 @@ "message": "Multifactor authentication failed" }, "includeSharedFolders": { - "message": "Include shared folders" + "message": "Συμπερίληψη κοινόχρηστων φακέλων" }, "lastPassEmail": { "message": "LastPass Email" }, "importingYourAccount": { - "message": "Importing your account..." + "message": "Εισαγωγή του λογαριασμού σας..." }, "lastPassMFARequired": { - "message": "LastPass multifactor authentication required" + "message": "Απαιτείται πολυμερής ταυτοποίηση LastPass" }, "lastPassMFADesc": { - "message": "Enter your one-time passcode from your authentication app" + "message": "Εισαγάγετε τον κωδικό μιας χρήσης από την εφαρμογή επαλήθευσης" }, "lastPassOOBDesc": { - "message": "Approve the login request in your authentication app or enter a one-time passcode." + "message": "Εγκρίνετε το αίτημα σύνδεσης στην εφαρμογή επαλήθευσης ή εισαγάγετε έναν κωδικό πρόσβασης μιας χρήσης." }, "passcode": { - "message": "Passcode" + "message": "Κωδικός" }, "lastPassMasterPassword": { - "message": "LastPass master password" + "message": "Κύριος κωδικός πρόσβασης LastPass" }, "lastPassAuthRequired": { - "message": "LastPass authentication required" + "message": "Απαιτείται ταυτοποίηση LastPass" }, "awaitingSSO": { - "message": "Awaiting SSO authentication" + "message": "Αναμονή ελέγχου ταυτότητας SSO" }, "awaitingSSODesc": { - "message": "Please continue to log in using your company credentials." + "message": "Παρακαλούμε συνεχίστε τη σύνδεση χρησιμοποιώντας τα στοιχεία της εταιρείας σας." }, "seeDetailedInstructions": { - "message": "See detailed instructions on our help site at", + "message": "Δείτε λεπτομερείς οδηγίες στην ιστοσελίδα βοήθειας μας στο", "description": "This is followed a by a hyperlink to the help website." }, "importDirectlyFromLastPass": { - "message": "Import directly from LastPass" + "message": "Εισαγωγή απευθείας από το LastPass" }, "importFromCSV": { - "message": "Import from CSV" + "message": "Εισαγωγή από CSV" }, "lastPassTryAgainCheckEmail": { - "message": "Try again or look for an email from LastPass to verify it's you." + "message": "Δοκιμάστε ξανά ή ψάξτε για ένα email από το LastPass για να επιβεβαιώσετε ότι είστε εσείς." }, "collection": { - "message": "Collection" + "message": "Συλλογή" }, "lastPassYubikeyDesc": { - "message": "Insert the YubiKey associated with your LastPass account into your computer's USB port, then touch its button." + "message": "Εισαγάγετε το YubiKey που σχετίζεται με το λογαριασμό LastPass στη θύρα USB του υπολογιστή σας και στη συνέχεια αγγίξτε το κουμπί του." }, "commonImportFormats": { - "message": "Common formats", + "message": "Κοινές μορφές", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Αντιμετώπιση Προβλημάτων" + }, + "disableHardwareAccelerationRestart": { + "message": "Απενεργοποίηση επιτάχυνσης υλικού και επανεκκίνηση" + }, + "enableHardwareAccelerationRestart": { + "message": "Ενεργοποίηση επιτάχυνσης υλικού και επανεκκίνηση" } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 3a4835f16a1..394a5951e98 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2696,5 +2696,11 @@ }, "enableHardwareAccelerationRestart": { "message": "Enable hardware acceleration and restart" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "passkeyRemoved": { + "message": "Passkey removed" } } diff --git a/apps/desktop/src/locales/en_GB/messages.json b/apps/desktop/src/locales/en_GB/messages.json index 25f6b61758c..9b68b6de49a 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index 680586cd71f..3ffc46eba10 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index 9327dceae49..dcffc08ea43 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index f30239ad75d..29c345d235f 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index c2780c3c970..663cd873e59 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index b3426a5146b..8970af13504 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index 5e7b26fbb48..a8a5758a142 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "فرمت‌های رایج", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index b04b859d35a..5c069578fae 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Yleiset muodot", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index 41cd3e9bcef..6e6d0abaa66 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index edecc8a4d60..90dc4b0f774 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -448,7 +448,7 @@ "message": "Rechercher dans la collection" }, "searchFolder": { - "message": "Rechercher dans un dossier" + "message": "Rechercher dans le dossier" }, "searchFavorites": { "message": "Rechercher parmi les favoris" @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Formats communs", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Résolution de problèmes" + }, + "disableHardwareAccelerationRestart": { + "message": "Désactiver l'accélération matérielle et redémarrer" + }, + "enableHardwareAccelerationRestart": { + "message": "Activer l'accélération matérielle et redémarrer" } } diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index f5f85b040c9..11694b8c9c9 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index 607c2d81d38..3a9517e8d3c 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -561,10 +561,10 @@ "message": "החשבון החדש שלך נוצר בהצלחה! כעת ניתן להתחבר למערכת." }, "youSuccessfullyLoggedIn": { - "message": "You successfully logged in" + "message": "נכנסת בהצלחה" }, "youMayCloseThisWindow": { - "message": "You may close this window" + "message": "אפשר לסגור את החלון הזה" }, "masterPassSent": { "message": "שלחנו לך אימייל עם רמז לסיסמה הראשית." @@ -1505,7 +1505,7 @@ "message": "יש לבצע אימות מחדש כדי לגשת לכספת שוב." }, "unlockMethodNeededToChangeTimeoutActionDesc": { - "message": "Set up an unlock method to change your vault timeout action." + "message": "יש להגדיר שיטת שחרור נעילה כדי לשנות את פעולת תום הזמן של הכספת שלך." }, "lock": { "message": "נעילה", @@ -1645,10 +1645,10 @@ "message": "הפעל שכבת אבטחה נוספת באמצעות בקשת אימות טביעות אצבע בעת יצירת קישור בין שולחן העבודה לדפדפן. כשהוא מופעל, זה דורש התערבות ואימות משתמש בכל פעם שנוצר חיבור." }, "enableHardwareAcceleration": { - "message": "Use hardware acceleration" + "message": "להשתמש בהאצת חומרה" }, "enableHardwareAccelerationDesc": { - "message": "By default this setting is ON. Turn OFF only if you experience graphical issues. Restart is required." + "message": "ההגדרה הזאת פעילה כברירת מחדל. יש לכבות רק אם יש כל מיני תקלות גרפיות. צריך להפעיל מחדש." }, "approve": { "message": "לְאַשֵׁר" @@ -2295,7 +2295,7 @@ "message": "Check known data breaches for this password" }, "important": { - "message": "Important:" + "message": "חשוב:" }, "masterPasswordHint": { "message": "Your master password cannot be recovered if you forget it!" @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "תסדירים נפוצים", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index 4b5634baf1f..86f17c1c835 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index 9f68d70a87b..5af3644eea3 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index f7e4052c171..0b61e5f6755 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Általános formátumok", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Hibaelhárítás" + }, + "disableHardwareAccelerationRestart": { + "message": "A hardveres gyorsítás letiltása és újraindítás" + }, + "enableHardwareAccelerationRestart": { + "message": "A hardveres gyorsítás engedélyezése és újraindítás" } } diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index 10a01a385c6..5290fd11dea 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index 5c1c750c853..2736a4a46ad 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Formati comuni", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Risoluzione problemi" + }, + "disableHardwareAccelerationRestart": { + "message": "Disattiva l'accelerazione hardware e riavvia" + }, + "enableHardwareAccelerationRestart": { + "message": "Attiva l'accelerazione hardware e riavvia" } } diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index 1a1a0e148b4..a4b213a5fde 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "一般的な形式", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "トラブルシューティング" + }, + "disableHardwareAccelerationRestart": { + "message": "ハードウェアアクセラレーションを無効にして再起動する" + }, + "enableHardwareAccelerationRestart": { + "message": "ハードウェアアクセラレーションを有効にして再起動する" } } diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index f5f85b040c9..11694b8c9c9 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index f5f85b040c9..11694b8c9c9 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index 1707f85a9bb..5f7ad11dcde 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index cd22b123863..c8e67198111 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index 61771644088..00186771fde 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Dažni formatai", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index 502f926c785..f82b762d6ca 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -1645,10 +1645,10 @@ "message": "Iespējo papildu drošības slāni, pieprasot atpazīšanas vārdkopas pārbaudi, kad tiek izveidota saikne starp darbvirsmu un pārlūku. Ir nepieciešama lietotāja darbība un apliecināšana katru reizi, kad tiek izveidots savienojums." }, "enableHardwareAcceleration": { - "message": "Use hardware acceleration" + "message": "Izmantot aparatūras paātrināšanu" }, "enableHardwareAccelerationDesc": { - "message": "By default this setting is ON. Turn OFF only if you experience graphical issues. Restart is required." + "message": "Pēc noklusējuma šis iestatījums ir ieslēgts. Izslēgt tikai tad, ja tiek pieredzētas attēlojuma nepilnības. Nepieciešama pārsāknēšana." }, "approve": { "message": "Apstiprināt" @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Izplatīti veidoli", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Sarežģījumu novēršana" + }, + "disableHardwareAccelerationRestart": { + "message": "Atspējot aparatūras paātrinājumu un pārsāknēt" + }, + "enableHardwareAccelerationRestart": { + "message": "Iespējot aparatūras paātrinājumu un pārsāknēt" } } diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index 4aa0c6598a3..533ec2ebba2 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index c025e77c41a..cba807216a5 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index f5f85b040c9..11694b8c9c9 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index dc9180ddbf2..05e3e7703ea 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index cda18630e6d..a20a6a62675 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Vanlige formater", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index 4eed6500a6b..40e3d11d889 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index 188a4ab3cbb..0ae432ef45f 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Veelvoorkomende formaten", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Probleemoplossing" + }, + "disableHardwareAccelerationRestart": { + "message": "Hardwareversnelling uitschakelen en herstarten" + }, + "enableHardwareAccelerationRestart": { + "message": "Hardwareversnelling inschakelen en herstarten" } } diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index d088661002d..d3119f6a105 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index b67bed747c5..20b704ef5e8 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index 3a8d6dd4ef8..60444211be8 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Popularne formaty", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Rozwiązywanie problemów" + }, + "disableHardwareAccelerationRestart": { + "message": "Wyłącz akcelerację sprzętową i uruchom ponownie" + }, + "enableHardwareAccelerationRestart": { + "message": "Włącz akcelerację sprzętową i uruchom ponownie" } } diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index 8c29fc80d78..4fb7c74889f 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Formatos comuns", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index 26635769abf..7c325bc5f91 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Formatos comuns", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Resolução de problemas" + }, + "disableHardwareAccelerationRestart": { + "message": "Desativar a aceleração de hardware e reiniciar" + }, + "enableHardwareAccelerationRestart": { + "message": "Ativar a aceleração de hardware e reiniciar" } } diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index b6c0016e679..082a4f590bd 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index 817c7078842..5eaad37d0f8 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Основные форматы", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Устранение проблем" + }, + "disableHardwareAccelerationRestart": { + "message": "Отключить аппаратное ускорение и перезапустить" + }, + "enableHardwareAccelerationRestart": { + "message": "Включить аппаратное ускорение и перезапустить" } } diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index 92326f43127..c72b2dd0e6f 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index 0cfa6a98e01..b8777adeb14 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Bežné formáty", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Riešenie problémov" + }, + "disableHardwareAccelerationRestart": { + "message": "Zakázať hardvérové zrýchlenie a reštartovať" + }, + "enableHardwareAccelerationRestart": { + "message": "Povoliť hardvérové zrýchlenie a reštartovať" } } diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index afdf7fc63a4..fac763dec9f 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index 37335b207cf..73469b26b40 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -1645,10 +1645,10 @@ "message": "Омогућите додатни ниво заштите захтевајући проверу фразе отиска прста приликом успостављања везе између desktop-а и прегледача. Када је омогућено, ово захтева интервенцију и верификацију корисника сваки пут када се успостави веза." }, "enableHardwareAcceleration": { - "message": "Use hardware acceleration" + "message": "Користи хардверско убрзање" }, "enableHardwareAccelerationDesc": { - "message": "By default this setting is ON. Turn OFF only if you experience graphical issues. Restart is required." + "message": "Подразумевано је ово подешавање УКЉУЧЕНО. ИСКЉУЧИТЕ само ако имате графичких проблема. Потребно је поновно покретање." }, "approve": { "message": "Одобри" @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Уобичајени формати", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Решавање проблема" + }, + "disableHardwareAccelerationRestart": { + "message": "Онемогућите хардверско убрзање и поново покрените" + }, + "enableHardwareAccelerationRestart": { + "message": "Омогућите хардверско убрзање и поново покрените" } } diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index 53fe6b58d28..c4c70123ac9 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Vanliga format", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index f5f85b040c9..11694b8c9c9 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index 29b04ef6f20..a664696d5b7 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index db28be4e5e7..ae3f217aa6b 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Sorun giderme" + }, + "disableHardwareAccelerationRestart": { + "message": "Donanım hızlandırmayı devre dışı bırakın ve yeniden başlatın" + }, + "enableHardwareAccelerationRestart": { + "message": "Donanım hızlandırmayı etkinleştirin ve yeniden başlatın" } } diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index e395c4409fe..cc5273b1bde 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Поширені формати", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Усунення проблем" + }, + "disableHardwareAccelerationRestart": { + "message": "Вимкнути апаратне прискорення і перезапустити" + }, + "enableHardwareAccelerationRestart": { + "message": "Увімкнути апаратне прискорення і перезапустити" } } diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index b97eef44455..f81cb2778d8 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 4bf1530019a..617e8dd9344 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -2685,7 +2685,16 @@ "message": "将与您的 LastPass 账户关联的 YubiKey 插入计算机的 USB 端口,然后触摸其按钮。" }, "commonImportFormats": { - "message": "通用格式", + "message": "常规格式", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "故障排除" + }, + "disableHardwareAccelerationRestart": { + "message": "禁用硬件加速并重启" + }, + "enableHardwareAccelerationRestart": { + "message": "启用硬件加速并重启" } } diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index a4d280def79..1659344550d 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -2687,5 +2687,14 @@ "commonImportFormats": { "message": "Common formats", "description": "Label indicating the most common import formats" + }, + "troubleshooting": { + "message": "Troubleshooting" + }, + "disableHardwareAccelerationRestart": { + "message": "Disable hardware acceleration and restart" + }, + "enableHardwareAccelerationRestart": { + "message": "Enable hardware acceleration and restart" } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 5cb6abac58b..67f08839c52 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -6,11 +6,15 @@ import { firstValueFrom } from "rxjs"; 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"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { DefaultBiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; +import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service"; +import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; @@ -45,6 +49,7 @@ import { ELECTRON_SUPPORTS_SECURE_STORAGE } from "./platform/services/electron-p import { ElectronStateService } from "./platform/services/electron-state.service"; import { ElectronStorageService } from "./platform/services/electron-storage.service"; import { I18nMainService } from "./platform/services/i18n.main.service"; +import { IllegalSecureStorageService } from "./platform/services/illegal-secure-storage.service"; import { ElectronMainMessagingService } from "./services/electron-main-messaging.service"; import { isMacAppStore } from "./utils"; @@ -62,6 +67,8 @@ export class Main { desktopSettingsService: DesktopSettingsService; migrationRunner: MigrationRunner; tokenService: TokenServiceAbstraction; + keyGenerationService: KeyGenerationServiceAbstraction; + encryptService: EncryptService; windowMain: WindowMain; messagingMain: MessagingMain; @@ -153,11 +160,28 @@ export class Main { this.environmentService = new DefaultEnvironmentService(stateProvider, accountService); + this.mainCryptoFunctionService = new MainCryptoFunctionService(); + this.mainCryptoFunctionService.init(); + + this.keyGenerationService = new KeyGenerationService(this.mainCryptoFunctionService); + + this.encryptService = new EncryptServiceImplementation( + this.mainCryptoFunctionService, + this.logService, + true, // log mac failures + ); + + // Note: secure storage service is not available and should not be called in the main background process. + const illegalSecureStorageService = new IllegalSecureStorageService(); + this.tokenService = new TokenService( singleUserStateProvider, globalStateProvider, ELECTRON_SUPPORTS_SECURE_STORAGE, - this.storageService, + illegalSecureStorageService, + this.keyGenerationService, + this.encryptService, + this.logService, ); this.migrationRunner = new MigrationRunner( @@ -239,9 +263,6 @@ export class Main { this.clipboardMain = new ClipboardMain(); this.clipboardMain.init(); - - this.mainCryptoFunctionService = new MainCryptoFunctionService(); - this.mainCryptoFunctionService.init(); } bootstrap() { diff --git a/apps/desktop/src/main/menu/menu.help.ts b/apps/desktop/src/main/menu/menu.help.ts index 46328e20891..7cc0fc26811 100644 --- a/apps/desktop/src/main/menu/menu.help.ts +++ b/apps/desktop/src/main/menu/menu.help.ts @@ -240,7 +240,11 @@ export class HelpMenu implements IMenubarMenu { await this.desktopSettingsService.setHardwareAcceleration( !this.hardwareAccelerationEnabled, ); - app.relaunch(); + // `app.relaunch` crashes the app on Mac Store builds. Disabling it for now. + // https://github.com/electron/electron/issues/41690 + if (!isMacAppStore()) { + app.relaunch(); + } app.exit(); }, }, diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 2862977ca24..b379e13d575 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2024.3.1", + "version": "2024.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2024.3.1", + "version": "2024.3.2", "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 4c748c4a055..cfc0b9b4e2b 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.3.1", + "version": "2024.3.2", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/desktop/src/platform/main/biometric/biometrics.service.abstraction.ts b/apps/desktop/src/platform/main/biometric/biometrics.service.abstraction.ts index 2d5c1d19ebb..fb7ce048b5a 100644 --- a/apps/desktop/src/platform/main/biometric/biometrics.service.abstraction.ts +++ b/apps/desktop/src/platform/main/biometric/biometrics.service.abstraction.ts @@ -1,6 +1,6 @@ export abstract class BiometricsServiceAbstraction { - osSupportsBiometric: () => Promise; - canAuthBiometric: ({ + abstract osSupportsBiometric(): Promise; + abstract canAuthBiometric({ service, key, userId, @@ -8,11 +8,11 @@ export abstract class BiometricsServiceAbstraction { service: string; key: string; userId: string; - }) => Promise; - authenticateBiometric: () => Promise; - getBiometricKey: (service: string, key: string) => Promise; - setBiometricKey: (service: string, key: string, value: string) => Promise; - setEncryptionKeyHalf: ({ + }): Promise; + abstract authenticateBiometric(): Promise; + abstract getBiometricKey(service: string, key: string): Promise; + abstract setBiometricKey(service: string, key: string, value: string): Promise; + abstract setEncryptionKeyHalf({ service, key, value, @@ -20,23 +20,23 @@ export abstract class BiometricsServiceAbstraction { service: string; key: string; value: string; - }) => void; - deleteBiometricKey: (service: string, key: string) => Promise; + }): void; + abstract deleteBiometricKey(service: string, key: string): Promise; } export interface OsBiometricService { - osSupportsBiometric: () => Promise; - authenticateBiometric: () => Promise; - getBiometricKey: ( + osSupportsBiometric(): Promise; + authenticateBiometric(): Promise; + getBiometricKey( service: string, key: string, clientKeyHalfB64: string | undefined, - ) => Promise; - setBiometricKey: ( + ): Promise; + setBiometricKey( service: string, key: string, value: string, clientKeyHalfB64: string | undefined, - ) => Promise; - deleteBiometricKey: (service: string, key: string) => Promise; + ): Promise; + deleteBiometricKey(service: string, key: string): Promise; } diff --git a/apps/desktop/src/platform/services/electron-state.service.ts b/apps/desktop/src/platform/services/electron-state.service.ts index f4399221d2d..33c97f48afe 100644 --- a/apps/desktop/src/platform/services/electron-state.service.ts +++ b/apps/desktop/src/platform/services/electron-state.service.ts @@ -1,47 +1,12 @@ -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; -import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service"; -import { DeviceKey } from "@bitwarden/common/types/key"; import { Account } from "../../models/account"; export class ElectronStateService extends BaseStateService { - private partialKeys = { - deviceKey: "_deviceKey", - }; - async addAccount(account: Account) { // Apply desktop overides to default account values account = new Account(account); await super.addAccount(account); } - - override async getDeviceKey(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); - if (options?.userId == null) { - return; - } - - const b64DeviceKey = await this.secureStorageService.get( - `${options.userId}${this.partialKeys.deviceKey}`, - options, - ); - - if (b64DeviceKey == null) { - return null; - } - - return new SymmetricCryptoKey(Utils.fromB64ToArray(b64DeviceKey)) as DeviceKey; - } - - override async setDeviceKey(value: DeviceKey, options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); - if (options?.userId == null) { - return; - } - - await this.saveSecureStorageKey(this.partialKeys.deviceKey, value.keyB64, options); - } } diff --git a/apps/desktop/src/platform/services/i18n.main.service.ts b/apps/desktop/src/platform/services/i18n.main.service.ts index edf79eccf00..bb2d1b1c1c7 100644 --- a/apps/desktop/src/platform/services/i18n.main.service.ts +++ b/apps/desktop/src/platform/services/i18n.main.service.ts @@ -35,6 +35,7 @@ export class I18nMainService extends BaseI18nService { "bs", "ca", "cs", + "cy", "da", "de", "el", @@ -48,6 +49,7 @@ export class I18nMainService extends BaseI18nService { "fi", "fil", "fr", + "gl", "he", "hi", "hr", @@ -59,13 +61,18 @@ export class I18nMainService extends BaseI18nService { "km", "kn", "ko", + "lt", "lv", "me", "ml", + "mr", + "my", "nb", + "ne", "nl", "nn", "pl", + "or", "pt-BR", "pt-PT", "ro", @@ -75,6 +82,7 @@ export class I18nMainService extends BaseI18nService { "sl", "sr", "sv", + "te", "th", "tr", "uk", diff --git a/apps/desktop/src/platform/services/i18n.renderer.service.ts b/apps/desktop/src/platform/services/i18n.renderer.service.ts index 87ad8b40183..18fe588f77d 100644 --- a/apps/desktop/src/platform/services/i18n.renderer.service.ts +++ b/apps/desktop/src/platform/services/i18n.renderer.service.ts @@ -28,6 +28,7 @@ export class I18nRendererService extends BaseI18nService { "bs", "ca", "cs", + "cy", "da", "de", "el", @@ -41,6 +42,7 @@ export class I18nRendererService extends BaseI18nService { "fi", "fil", "fr", + "gl", "he", "hi", "hr", @@ -52,13 +54,18 @@ export class I18nRendererService extends BaseI18nService { "km", "kn", "ko", + "lt", "lv", "me", "ml", + "mr", + "my", "nb", + "ne", "nl", "nn", "pl", + "or", "pt-BR", "pt-PT", "ro", @@ -68,6 +75,7 @@ export class I18nRendererService extends BaseI18nService { "sl", "sr", "sv", + "te", "th", "tr", "uk", diff --git a/apps/desktop/src/platform/services/illegal-secure-storage.service.ts b/apps/desktop/src/platform/services/illegal-secure-storage.service.ts new file mode 100644 index 00000000000..12f86226bef --- /dev/null +++ b/apps/desktop/src/platform/services/illegal-secure-storage.service.ts @@ -0,0 +1,28 @@ +import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; + +export class IllegalSecureStorageService implements AbstractStorageService { + constructor() {} + + get valuesRequireDeserialization(): boolean { + throw new Error("Method not implemented."); + } + has(key: string, options?: StorageOptions): Promise { + throw new Error("Method not implemented."); + } + save(key: string, obj: T, options?: StorageOptions): Promise { + throw new Error("Method not implemented."); + } + async get(key: string): Promise { + throw new Error("Method not implemented."); + } + async set(key: string, obj: T): Promise { + throw new Error("Method not implemented."); + } + async remove(key: string): Promise { + throw new Error("Method not implemented."); + } + async clear(): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/apps/desktop/src/services/native-message-handler.service.ts b/apps/desktop/src/services/native-message-handler.service.ts index 785b65195a0..ebe1ee62484 100644 --- a/apps/desktop/src/services/native-message-handler.service.ts +++ b/apps/desktop/src/services/native-message-handler.service.ts @@ -5,10 +5,10 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { StateService } from "@bitwarden/common/platform/services/state.service"; import { DialogService } from "@bitwarden/components"; import { VerifyNativeMessagingDialogComponent } from "../app/components/verify-native-messaging-dialog.component"; diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.html b/apps/desktop/src/vault/app/vault/add-edit.component.html index 43add532543..ea7be929351 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.html +++ b/apps/desktop/src/vault/app/vault/add-edit.component.html @@ -116,14 +116,25 @@
- {{ "typePasskey" | i18n }} - {{ fido2CredentialCreationDateValue }} + +
+ {{ "typePasskey" | i18n }} + {{ fido2CredentialCreationDateValue }} +
diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.ts b/apps/desktop/src/vault/app/vault/add-edit.component.ts index 8532b7462a8..b89beebaa63 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.ts +++ b/apps/desktop/src/vault/app/vault/add-edit.component.ts @@ -8,7 +8,7 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve 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 { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -49,7 +49,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnChanges, sendApiService: SendApiService, dialogService: DialogService, datePipe: DatePipe, - configService: ConfigServiceAbstraction, + configService: ConfigService, ) { super( cipherService, diff --git a/apps/web/jest.config.js b/apps/web/jest.config.js index cde02cd9959..f121823adee 100644 --- a/apps/web/jest.config.js +++ b/apps/web/jest.config.js @@ -9,7 +9,11 @@ module.exports = { ...sharedConfig, preset: "jest-preset-angular", setupFilesAfterEnv: ["/test.setup.ts"], - moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { - prefix: "/", - }), + moduleNameMapper: pathsToModuleNameMapper( + // lets us use @bitwarden/common/spec in web tests + { "@bitwarden/common/spec": ["../../libs/common/spec"], ...(compilerOptions?.paths ?? {}) }, + { + prefix: "/", + }, + ), }; diff --git a/apps/web/package.json b/apps/web/package.json index 3fad4b14ae1..5b049dcb9d7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2024.3.0", + "version": "2024.3.1", "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/core/services/group/group.service.ts b/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts index 33a3069e1dd..63431cd6abe 100644 --- a/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts +++ b/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts @@ -3,7 +3,7 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CoreOrganizationModule } from "../../core-organization.module"; import { GroupView } from "../../views/group.view"; @@ -18,7 +18,7 @@ import { GroupDetailsResponse, GroupResponse } from "./responses/group.response" export class GroupService { constructor( protected apiService: ApiService, - protected configService: ConfigServiceAbstraction, + protected configService: ConfigService, ) {} async get(orgId: string, groupId: string): Promise { @@ -52,7 +52,7 @@ export class GroupService { export class InternalGroupService extends GroupService { constructor( protected apiService: ApiService, - protected configService: ConfigServiceAbstraction, + protected configService: ConfigService, ) { super(apiService, configService); } diff --git a/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts b/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts index a1d1bc3e238..399140e3ea6 100644 --- a/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts +++ b/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts @@ -6,7 +6,7 @@ import { OrganizationUserUpdateRequest, } from "@bitwarden/common/admin-console/abstractions/organization-user/requests"; import { OrganizationUserDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-user/responses"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CoreOrganizationModule } from "../core-organization.module"; import { OrganizationUserAdminView } from "../views/organization-user-admin-view"; @@ -14,7 +14,7 @@ import { OrganizationUserAdminView } from "../views/organization-user-admin-view @Injectable({ providedIn: CoreOrganizationModule }) export class UserAdminService { constructor( - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private organizationUserService: OrganizationUserService, ) {} 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 90010160aa9..1924476327e 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 @@ -17,7 +17,7 @@ import { } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigServiceAbstraction as ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { BannerModule, IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components"; diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts index 668b09eb7ef..752122de004 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts @@ -24,7 +24,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ProductType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; @@ -148,7 +148,7 @@ export class MemberDialogComponent implements OnDestroy { private userService: UserAdminService, private organizationUserService: OrganizationUserService, private dialogService: DialogService, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private accountService: AccountService, organizationService: OrganizationService, ) { diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.ts b/apps/web/src/app/admin-console/organizations/settings/account.component.ts index 8527aa1b172..b218e680e37 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.ts @@ -11,7 +11,7 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/ import { OrganizationUpdateRequest } from "@bitwarden/common/admin-console/models/request/organization-update.request"; import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -95,7 +95,7 @@ export class AccountComponent { private organizationApiService: OrganizationApiServiceAbstraction, private dialogService: DialogService, private formBuilder: FormBuilder, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, ) {} async ngOnInit() { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index a1b74566279..32f4ee67e20 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -16,7 +16,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -83,7 +83,7 @@ export class AppComponent implements OnDestroy, OnInit { private policyService: InternalPolicyService, protected policyListService: PolicyListService, private keyConnectorService: KeyConnectorService, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private dialogService: DialogService, private biometricStateService: BiometricStateService, private stateEventRunnerService: StateEventRunnerService, @@ -158,7 +158,7 @@ export class AppComponent implements OnDestroy, OnInit { break; case "syncCompleted": if (message.successfully) { - this.configService.triggerServerConfigFetch(); + await this.configService.ensureConfigFetched(); } break; case "upgradeOrganization": { @@ -276,7 +276,6 @@ export class AppComponent implements OnDestroy, OnInit { this.collectionService.clear(userId), this.policyService.clear(userId), this.passwordGenerationService.clear(), - this.keyConnectorService.clear(), this.biometricStateService.logout(userId as UserId), this.paymentMethodWarningService.clear(), ]); diff --git a/apps/web/src/app/auth/hint.component.ts b/apps/web/src/app/auth/hint.component.ts index d3a7c004313..116b0f3f830 100644 --- a/apps/web/src/app/auth/hint.component.ts +++ b/apps/web/src/app/auth/hint.component.ts @@ -2,8 +2,8 @@ import { Component } from "@angular/core"; import { Router } from "@angular/router"; import { HintComponent as BaseHintComponent } from "@bitwarden/angular/auth/components/hint.component"; +import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -19,8 +19,8 @@ export class HintComponent extends BaseHintComponent { apiService: ApiService, platformUtilsService: PlatformUtilsService, logService: LogService, - loginService: LoginService, + loginEmailService: LoginEmailServiceAbstraction, ) { - super(router, i18nService, apiService, platformUtilsService, logService, loginService); + super(router, i18nService, apiService, platformUtilsService, logService, loginEmailService); } } diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts index 93ee8576175..09c7bf9ace4 100644 --- a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts @@ -2,14 +2,17 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -39,7 +42,10 @@ describe("KeyRotationService", () => { let mockCryptoService: MockProxy; let mockEncryptService: MockProxy; let mockStateService: MockProxy; - let mockConfigService: MockProxy; + let mockConfigService: MockProxy; + + const mockUserId = Utils.newGuid() as UserId; + const mockAccountService: FakeAccountService = mockAccountServiceWith(mockUserId); beforeAll(() => { mockApiService = mock(); @@ -52,7 +58,7 @@ describe("KeyRotationService", () => { mockCryptoService = mock(); mockEncryptService = mock(); mockStateService = mock(); - mockConfigService = mock(); + mockConfigService = mock(); keyRotationService = new UserKeyRotationService( mockApiService, @@ -65,6 +71,7 @@ describe("KeyRotationService", () => { mockCryptoService, mockEncryptService, mockStateService, + mockAccountService, mockConfigService, ); }); diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts index bb4c3494dd0..03bc604b4d8 100644 --- a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts @@ -1,9 +1,10 @@ import { Injectable } from "@angular/core"; import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -34,7 +35,8 @@ export class UserKeyRotationService { private cryptoService: CryptoService, private encryptService: EncryptService, private stateService: StateService, - private configService: ConfigServiceAbstraction, + private accountService: AccountService, + private configService: ConfigService, ) {} /** @@ -90,7 +92,12 @@ export class UserKeyRotationService { await this.rotateUserKeyAndEncryptedDataLegacy(request); } - await this.deviceTrustCryptoService.rotateDevicesTrust(newUserKey, masterPasswordHash); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + await this.deviceTrustCryptoService.rotateDevicesTrust( + activeAccount.id, + newUserKey, + masterPasswordHash, + ); } private async encryptPrivateKey(newUserKey: UserKey): Promise { diff --git a/apps/web/src/app/auth/lock.component.ts b/apps/web/src/app/auth/lock.component.ts index c4f8d276bb0..a1d47243969 100644 --- a/apps/web/src/app/auth/lock.component.ts +++ b/apps/web/src/app/auth/lock.component.ts @@ -8,6 +8,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -47,6 +48,7 @@ export class LockComponent extends BaseLockComponent { userVerificationService: UserVerificationService, pinCryptoService: PinCryptoServiceAbstraction, biometricStateService: BiometricStateService, + accountService: AccountService, ) { super( router, @@ -69,6 +71,7 @@ export class LockComponent extends BaseLockComponent { userVerificationService, pinCryptoService, biometricStateService, + accountService, ); } diff --git a/apps/web/src/app/auth/login/login-via-auth-request.component.ts b/apps/web/src/app/auth/login/login-via-auth-request.component.ts index a3bf1160a3c..5bca7183041 100644 --- a/apps/web/src/app/auth/login/login-via-auth-request.component.ts +++ b/apps/web/src/app/auth/login/login-via-auth-request.component.ts @@ -1,75 +1,9 @@ -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { Router } from "@angular/router"; +import { Component } from "@angular/core"; import { LoginViaAuthRequestComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/auth/components/login-via-auth-request.component"; -import { - AuthRequestServiceAbstraction, - LoginStrategyServiceAbstraction, -} from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service"; -import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; - -import { StateService } from "../../core"; @Component({ selector: "app-login-via-auth-request", templateUrl: "login-via-auth-request.component.html", }) -export class LoginViaAuthRequestComponent - extends BaseLoginWithDeviceComponent - implements OnInit, OnDestroy -{ - constructor( - router: Router, - cryptoService: CryptoService, - cryptoFunctionService: CryptoFunctionService, - appIdService: AppIdService, - passwordGenerationService: PasswordGenerationServiceAbstraction, - apiService: ApiService, - authService: AuthService, - logService: LogService, - environmentService: EnvironmentService, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - anonymousHubService: AnonymousHubService, - validationService: ValidationService, - stateService: StateService, - loginService: LoginService, - deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, - authRequestService: AuthRequestServiceAbstraction, - loginStrategyService: LoginStrategyServiceAbstraction, - ) { - super( - router, - cryptoService, - cryptoFunctionService, - appIdService, - passwordGenerationService, - apiService, - authService, - logService, - environmentService, - i18nService, - platformUtilsService, - anonymousHubService, - validationService, - stateService, - loginService, - deviceTrustCryptoService, - authRequestService, - loginStrategyService, - ); - } -} +export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent {} diff --git a/apps/web/src/app/auth/login/login.component.html b/apps/web/src/app/auth/login/login.component.html index 5c68058a3cb..0e29a342786 100644 --- a/apps/web/src/app/auth/login/login.component.html +++ b/apps/web/src/app/auth/login/login.component.html @@ -1,6 +1,6 @@
{{ "getMasterPasswordHint" | i18n }}
diff --git a/apps/web/src/app/auth/login/login.component.ts b/apps/web/src/app/auth/login/login.component.ts index 1d2d1859e94..9f628b9389e 100644 --- a/apps/web/src/app/auth/login/login.component.ts +++ b/apps/web/src/app/auth/login/login.component.ts @@ -6,7 +6,10 @@ import { first } from "rxjs/operators"; import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/auth/components/login.component"; import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; -import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common"; +import { + LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, +} from "@bitwarden/auth/common"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data"; @@ -14,7 +17,6 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; @@ -62,7 +64,7 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { private routerService: RouterService, formBuilder: FormBuilder, formValidationErrorService: FormValidationErrorsService, - loginService: LoginService, + loginEmailService: LoginEmailServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction, webAuthnLoginService: WebAuthnLoginServiceAbstraction, ) { @@ -82,7 +84,7 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { formBuilder, formValidationErrorService, route, - loginService, + loginEmailService, ssoLoginService, webAuthnLoginService, ); @@ -173,14 +175,14 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { } } - this.loginService.clearValues(); + this.loginEmailService.clearValues(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate([this.successRoute]); } goToHint() { - this.setFormValues(); + this.setLoginEmailValues(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigateByUrl("/hint"); @@ -201,15 +203,6 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { this.router.navigate(["/register"]); } - async submit() { - const rememberEmail = this.formGroup.value.rememberEmail; - - if (!rememberEmail) { - await this.stateService.setRememberedEmail(null); - } - await super.submit(false); - } - protected override handleMigrateEncryptionKey(result: AuthResult): boolean { if (!result.requiresEncryptionKeyMigration) { return false; diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts index d20f0cd1bd3..9312ce5fc03 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts @@ -6,7 +6,7 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve 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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -52,7 +52,7 @@ export class EmergencyAddEditCipherComponent extends BaseAddEditComponent { sendApiService: SendApiService, dialogService: DialogService, datePipe: DatePipe, - configService: ConfigServiceAbstraction, + configService: ConfigService, billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( diff --git a/apps/web/src/app/auth/sso.component.ts b/apps/web/src/app/auth/sso.component.ts index 2ef4f3eb155..cdd979aa898 100644 --- a/apps/web/src/app/auth/sso.component.ts +++ b/apps/web/src/app/auth/sso.component.ts @@ -13,7 +13,7 @@ import { OrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-co import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { HttpStatusCode } from "@bitwarden/common/enums"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -45,7 +45,7 @@ export class SsoComponent extends BaseSsoComponent { private orgDomainApiService: OrgDomainApiServiceAbstraction, private validationService: ValidationService, userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - configService: ConfigServiceAbstraction, + configService: ConfigService, ) { super( ssoLoginService, diff --git a/apps/web/src/app/auth/two-factor.component.ts b/apps/web/src/app/auth/two-factor.component.ts index a47a7a28487..65bf1dba58a 100644 --- a/apps/web/src/app/auth/two-factor.component.ts +++ b/apps/web/src/app/auth/two-factor.component.ts @@ -6,16 +6,16 @@ import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -46,10 +46,10 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest logService: LogService, twoFactorService: TwoFactorService, appIdService: AppIdService, - loginService: LoginService, + loginEmailService: LoginEmailServiceAbstraction, userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction, - configService: ConfigServiceAbstraction, + configService: ConfigService, @Inject(WINDOW) protected win: Window, ) { super( @@ -65,7 +65,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest logService, twoFactorService, appIdService, - loginService, + loginEmailService, userDecryptionOptionsService, ssoLoginService, configService, @@ -103,7 +103,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest } goAfterLogIn = async () => { - this.loginService.clearValues(); + this.loginEmailService.clearValues(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate([this.successRoute], { diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 7f53fba1c03..5f767d85c46 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -25,7 +25,13 @@ *ngIf="subscriptionMarkedForCancel" >

{{ "subscriptionPendingCanceled" | i18n }}

- diff --git a/apps/web/src/app/billing/shared/add-credit.component.ts b/apps/web/src/app/billing/shared/add-credit.component.ts index 25d49fac9e4..71050a9a6e1 100644 --- a/apps/web/src/app/billing/shared/add-credit.component.ts +++ b/apps/web/src/app/billing/shared/add-credit.component.ts @@ -13,7 +13,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PaymentMethodType } from "@bitwarden/common/billing/enums"; import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -57,7 +57,7 @@ export class AddCreditComponent implements OnInit { private platformUtilsService: PlatformUtilsService, private organizationService: OrganizationService, private logService: LogService, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, ) { const payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig; this.ppButtonFormAction = payPalConfig.buttonAction; diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index e2d3f64f2d6..bd514b1d180 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -16,8 +16,6 @@ import { import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service"; -import { LoginService } from "@bitwarden/common/auth/services/login.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -29,6 +27,7 @@ import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/ import { ThemeType } from "@bitwarden/common/platform/enums"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; +// eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; @@ -117,11 +116,6 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service"; provide: FileDownloadService, useClass: WebFileDownloadService, }, - { - provide: LoginServiceAbstraction, - useClass: LoginService, - deps: [StateService], - }, CollectionAdminService, { provide: OBSERVABLE_DISK_LOCAL_STORAGE, diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts index 60f3dea9151..d5576d3bf70 100644 --- a/apps/web/src/app/core/init.service.ts +++ b/apps/web/src/app/core/init.service.ts @@ -10,7 +10,6 @@ import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/pla import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; -import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; @@ -28,7 +27,6 @@ export class InitService { private cryptoService: CryptoServiceAbstraction, private themingService: AbstractThemingService, private encryptService: EncryptService, - private configService: ConfigService, @Inject(DOCUMENT) private document: Document, ) {} @@ -46,8 +44,6 @@ export class InitService { this.themingService.applyThemeChangesTo(this.document); const containerService = new ContainerService(this.cryptoService, this.encryptService); containerService.attachToGlobal(this.win); - - this.configService.init(); }; } } diff --git a/apps/web/src/app/core/state/state.service.ts b/apps/web/src/app/core/state/state.service.ts index a80384d1798..54e456d34c3 100644 --- a/apps/web/src/app/core/state/state.service.ts +++ b/apps/web/src/app/core/state/state.service.ts @@ -18,7 +18,6 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service"; -import { SendData } from "@bitwarden/common/tools/send/models/data/send.data"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Account } from "./account"; @@ -71,19 +70,6 @@ export class StateService extends BaseStateService { return await super.setEncryptedCiphers(value, options); } - async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> { - options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); - return await super.getEncryptedSends(options); - } - - async setEncryptedSends( - value: { [id: string]: SendData }, - options?: StorageOptions, - ): Promise { - options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); - return await super.setEncryptedSends(value, options); - } - override async getLastSync(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.getLastSync(options); diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 2e1813697ef..ee30bed0d67 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -9,7 +9,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigServiceAbstraction as ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components"; diff --git a/apps/web/src/app/platform/web-migration-runner.ts b/apps/web/src/app/platform/web-migration-runner.ts index e8f44f7f412..4bd1d2d4b5f 100644 --- a/apps/web/src/app/platform/web-migration-runner.ts +++ b/apps/web/src/app/platform/web-migration-runner.ts @@ -46,7 +46,7 @@ class WebMigrationHelper extends MigrationHelper { storageService: WindowStorageService, logService: LogService, ) { - super(currentVersion, storageService, logService); + super(currentVersion, storageService, logService, "web-disk-local"); this.diskLocalStorageService = storageService; } diff --git a/apps/web/src/app/tools/vault-export/export.component.ts b/apps/web/src/app/tools/vault-export/export.component.ts index 8b14092f20e..4fdd3ff9e08 100644 --- a/apps/web/src/app/tools/vault-export/export.component.ts +++ b/apps/web/src/app/tools/vault-export/export.component.ts @@ -1,7 +1,6 @@ import { Component } from "@angular/core"; import { UntypedFormBuilder } from "@angular/forms"; -import { ExportComponent as BaseExportComponent } from "@bitwarden/angular/tools/export/components/export.component"; import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -13,6 +12,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; +import { ExportComponent as BaseExportComponent } from "@bitwarden/vault-export-ui"; @Component({ selector: "app-export", @@ -95,7 +95,6 @@ export class ExportComponent extends BaseExportComponent { } const result = await UserVerificationDialogComponent.open(this.dialogService, { - clientSideOnlyVerification: true, title: "confirmVaultExport", bodyText: confirmDescription, confirmButtonOptions: { diff --git a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts index 357d2217e47..722ab972fcd 100644 --- a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.ts @@ -18,7 +18,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -107,7 +107,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private organizationUserService: OrganizationUserService, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private dialogService: DialogService, private changeDetectorRef: ChangeDetectorRef, ) { diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 66d4a559cee..7a8e858ba57 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -37,7 +37,7 @@ export class VaultItemsComponent { @Input() showBulkMove: boolean; @Input() showBulkTrashOptions: boolean; // Encompasses functionality only available from the organization vault context - @Input() showAdminActions: boolean; + @Input() showAdminActions = false; @Input() allOrganizations: Organization[] = []; @Input() allCollections: CollectionView[] = []; @Input() allGroups: GroupView[] = []; @@ -91,7 +91,7 @@ export class VaultItemsComponent { } get bulkAssignToCollectionsAllowed() { - return this.ciphers.length > 0; + return this.showBulkAddToCollections && this.ciphers.length > 0; } protected canEditCollection(collection: CollectionView): boolean { diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index 05659de073c..ad80c9f4e58 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -8,7 +8,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; @@ -92,7 +92,7 @@ export default { } as Partial, }, { - provide: ConfigServiceAbstraction, + provide: ConfigService, useValue: { getFeatureFlag() { // does not currently affect any display logic, default all to OFF diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.html b/apps/web/src/app/vault/individual-vault/add-edit.component.html index 12b41f181d6..85075acfdd2 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.html +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.html @@ -192,11 +192,11 @@ -
-
+
+
+ +
diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.ts b/apps/web/src/app/vault/individual-vault/add-edit.component.ts index 8332b7e95f1..56f18c4a3bd 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.ts @@ -9,7 +9,7 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType, ProductType } from "@bitwarden/common/enums"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -65,7 +65,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On sendApiService: SendApiService, dialogService: DialogService, datePipe: DatePipe, - configService: ConfigServiceAbstraction, + configService: ConfigService, private billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts index 34247176305..8967336f758 100644 --- a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts @@ -5,7 +5,7 @@ import { Subject, of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -21,7 +21,7 @@ describe("VaultOnboardingComponent", () => { let mockApiService: Partial; let mockPolicyService: MockProxy; let mockI18nService: MockProxy; - let mockConfigService: MockProxy; + let mockConfigService: MockProxy; let mockVaultOnboardingService: MockProxy; let mockStateProvider: Partial; let setInstallExtLinkSpy: any; @@ -34,7 +34,7 @@ describe("VaultOnboardingComponent", () => { mockApiService = { getProfile: jest.fn(), }; - mockConfigService = mock(); + mockConfigService = mock(); mockVaultOnboardingService = mock(); mockStateProvider = { getActive: jest.fn().mockReturnValue( @@ -56,7 +56,7 @@ describe("VaultOnboardingComponent", () => { { provide: VaultOnboardingServiceAbstraction, useValue: mockVaultOnboardingService }, { provide: I18nService, useValue: mockI18nService }, { provide: ApiService, useValue: mockApiService }, - { provide: ConfigServiceAbstraction, useValue: mockConfigService }, + { provide: ConfigService, useValue: mockConfigService }, { provide: StateProvider, useValue: mockStateProvider }, ], }).compileComponents(); diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts index 22f56a85a9d..dc3a41cf155 100644 --- a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts @@ -17,7 +17,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { VaultOnboardingMessages } from "@bitwarden/common/vault/enums/vault-onboarding.enum"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -43,7 +43,7 @@ export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { isIndividualPolicyVault: boolean; private destroy$ = new Subject(); isNewAccount: boolean; - private readonly onboardingReleaseDate = new Date("2024-01-01"); + private readonly onboardingReleaseDate = new Date("2024-04-02"); showOnboardingAccess$: Observable; protected currentTasks: VaultOnboardingTasks; @@ -55,7 +55,7 @@ export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { protected platformUtilsService: PlatformUtilsService, protected policyService: PolicyService, private apiService: ApiService, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private vaultOnboardingService: VaultOnboardingServiceAbstraction, ) {} diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index b59e554f5ac..5f90f8d440b 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -50,7 +50,6 @@ [cloneableOrganizationCiphers]="false" [showAdminActions]="false" (onEvent)="onVaultItemsEvent($event)" - [showBulkEditCollectionAccess]="showBulkCollectionAccess$ | async" >
| undefined; protected canCreateCollections = false; protected currentSearchText$: Observable; - protected showBulkCollectionAccess$ = this.configService.getFeatureFlag$( - FeatureFlag.BulkCollectionAccess, - false, - ); private searchText$ = new Subject(); private refresh$ = new BehaviorSubject(null); @@ -180,7 +175,7 @@ export class VaultComponent implements OnInit, OnDestroy { private eventCollectionService: EventCollectionService, private searchService: SearchService, private searchPipe: SearchPipe, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private apiService: ApiService, private userVerificationService: UserVerificationService, private billingAccountProfileStateService: BillingAccountProfileStateService, @@ -246,7 +241,7 @@ export class VaultComponent implements OnInit, OnDestroy { }); const filter$ = this.routedVaultFilterService.filter$; - const allCollections$ = Utils.asyncToObservable(() => this.collectionService.getAllDecrypted()); + const allCollections$ = this.collectionService.decryptedCollections$; const nestedCollections$ = allCollections$.pipe( map((collections) => getNestedCollectionTree(collections)), ); diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index cb879dfcc75..c4213989c6b 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -7,7 +7,7 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve 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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -54,7 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent { sendApiService: SendApiService, dialogService: DialogService, datePipe: DatePipe, - configService: ConfigServiceAbstraction, + configService: ConfigService, billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( @@ -105,8 +105,14 @@ export class AddEditComponent extends BaseAddEditComponent { } protected async loadCipher() { - if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { - return await super.loadCipher(); + // Calling loadCipher first to assess if the cipher is unassigned. If null use apiService getCipherAdmin + const firstCipherCheck = await super.loadCipher(); + + if ( + !this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled) && + firstCipherCheck != null + ) { + return firstCipherCheck; } const response = await this.apiService.getCipherAdmin(this.cipherId); const data = new CipherData(response); diff --git a/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.ts b/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.ts index 04edce8543f..091c6461780 100644 --- a/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.ts +++ b/apps/web/src/app/vault/org-vault/bulk-collection-assignment-dialog/bulk-collection-assignment-dialog.component.ts @@ -4,7 +4,7 @@ import { Subject } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; @@ -65,7 +65,7 @@ export class BulkCollectionAssignmentDialogComponent implements OnDestroy, OnIni private cipherService: CipherService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private organizationService: OrganizationService, ) {} diff --git a/apps/web/src/app/vault/org-vault/vault.component.html b/apps/web/src/app/vault/org-vault/vault.component.html index 242a03b9955..4bec92b5db3 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.html +++ b/apps/web/src/app/vault/org-vault/vault.component.html @@ -51,9 +51,7 @@ [cloneableOrganizationCiphers]="true" [showAdminActions]="true" (onEvent)="onVaultItemsEvent($event)" - [showBulkEditCollectionAccess]=" - (showBulkEditCollectionAccess$ | async) && organization?.flexibleCollections - " + [showBulkEditCollectionAccess]="organization?.flexibleCollections" [showBulkAddToCollections]="organization?.flexibleCollections" [viewingOrgVault]="true" > diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index 6691404b3db..a267612bd62 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -40,7 +40,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { EventType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -143,10 +143,6 @@ export class VaultComponent implements OnInit, OnDestroy { protected currentSearchText$: Observable; protected editableCollections$: Observable; protected allCollectionsWithoutUnassigned$: Observable; - protected showBulkEditCollectionAccess$ = this.configService.getFeatureFlag$( - FeatureFlag.BulkCollectionAccess, - false, - ); private _flexibleCollectionsV1FlagEnabled: boolean; protected get flexibleCollectionsV1Enabled(): boolean { @@ -184,7 +180,7 @@ export class VaultComponent implements OnInit, OnDestroy { private totpService: TotpService, private apiService: ApiService, private collectionService: CollectionService, - protected configService: ConfigServiceAbstraction, + protected configService: ConfigService, ) {} async ngOnInit() { diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 1900c9cf8ba..3677e54c1ca 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index ba9a6f75a0e..f5353f2234d 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index cc3391432ed..bc30012efe0 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Özünüzü kolleksiyalara əlavə edə bilməzsiniz." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index 795a1df6731..2ec27ae4bee 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index 3bfcd004985..a0d1781736c 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Не може да добавяте себе си към колекции." + }, + "assign": { + "message": "Свързване" + }, + "assignToCollections": { + "message": "Свързване с колекции" + }, + "assignToTheseCollections": { + "message": "Свързване с тези колекции" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Изберете колекциите, с които да бъдат споделени тези елементи. Когато даден елемент бъде променен в една колекция, промяната ще бъде отразена във всички колекции. Само членовете на организацията с достъп до тези колекции ще могат да виждат елементите." + }, + "selectCollectionsToAssign": { + "message": "Изберете колекции за свързване" + }, + "noCollectionsAssigned": { + "message": "Няма свързани колекции" + }, + "successfullyAssignedCollections": { + "message": "Успешно свързване на колекциите" + }, + "bulkCollectionAssignmentWarning": { + "message": "Избрали сте $TOTAL_COUNT$ елемента Не можете да промените $READONLY_COUNT$ от елементите, тъй като нямате право за редактиране.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Елементи" } } diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index bcdb6b09d50..9d0ccd3b509 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index efb1d1a5645..84084847d41 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index 976fdecda7d..e23f7acec4e 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "No podeu afegir-vos a les col·leccions." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 41956ad180c..523f1faa103 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Do kolekcí nemůžete přidat sami sebe." + }, + "assign": { + "message": "Přiřadit" + }, + "assignToCollections": { + "message": "Přiřadit ke kolekcím" + }, + "assignToTheseCollections": { + "message": "Přiřadit k těmto kolekcím" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Vyberte kolekce, se kterými budou položky sdíleny. Jakmile bude položka aktualizována v jedné kolekci, bude zobrazena ve všech kolekcích. Jen členové organizace s přístupem k těmto kolekcím budou moci vidět položky." + }, + "selectCollectionsToAssign": { + "message": "Vyberte kolekce pro přiřazení" + }, + "noCollectionsAssigned": { + "message": "Nebyly přiřazeny žádné kolekce" + }, + "successfullyAssignedCollections": { + "message": "Kolekce byly úspěšně přiřazeny" + }, + "bulkCollectionAssignmentWarning": { + "message": "Vybrali jste $TOTAL_COUNT$ položek. Nemůžete aktualizovat $READONLY_COUNT$ položek, protože nemáte oprávnění k úpravám.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Položky" } } diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index cb19cb87800..4781d9d3b6b 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index c8fab2d0e3d..bcb1c162357 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Man kan ikke føje sig selv til samlinger." + }, + "assign": { + "message": "Tildel" + }, + "assignToCollections": { + "message": "Tildel til samlinger" + }, + "assignToTheseCollections": { + "message": "Tildel til samlinger" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Vælg de samlinger som emnerne vil blive delt med. Når et emne er opdateret i en samling, vil det blive afspejlet i alle samlinger. Kun organisationsmedlemmer med adgang til disse samlinger vil kunne se emnerne." + }, + "selectCollectionsToAssign": { + "message": "Vælg samlinger at tildele" + }, + "noCollectionsAssigned": { + "message": "Ingen samlinger er blevet tildelt" + }, + "successfullyAssignedCollections": { + "message": "Samlinger hermed tildelt" + }, + "bulkCollectionAssignmentWarning": { + "message": "Der er valgt $TOTAL_COUNT$ emner. $READONLY_COUNT$ af emnerne kan ikke opdateres, da du ikke har redigeringsrettigheder.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Emner" } } diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index e71663444bf..c3e5a9bba5b 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Du kannst dich nicht selbst zu Sammlungen hinzufügen." + }, + "assign": { + "message": "Zuweisen" + }, + "assignToCollections": { + "message": "Sammlungen zuweisen" + }, + "assignToTheseCollections": { + "message": "Diesen Sammlungen zuweisen" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Wähle die Sammlungen, mit denen die Einträge geteilt werden. Sobald ein Eintrag in einer Sammlung aktualisiert wird, wird es in allen Sammlungen reflektiert. Nur Mitglieder von Organisationen mit Zugang zu diesen Sammlungen können die Einträge sehen." + }, + "selectCollectionsToAssign": { + "message": "Zu zuweisende Sammlungen auswählen" + }, + "noCollectionsAssigned": { + "message": "Es wurden keine Sammlungen zugewiesen" + }, + "successfullyAssignedCollections": { + "message": "Sammlungen erfolgreich zugewiesen" + }, + "bulkCollectionAssignmentWarning": { + "message": "Du hast $TOTAL_COUNT$ Einträge ausgewählt. Du kannst $READONLY_COUNT$ der Einträge nicht aktualisieren, da du keine Bearbeitungsrechte hast.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Einträge" } } diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index 3fd8501c6e7..0babeee15df 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 95d1b03e725..b8e5a5ff4d5 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -4956,6 +4956,9 @@ "addExistingOrganization": { "message": "Add existing organization" }, + "addNewOrganization": { + "message": "Add new organization" + }, "myProvider": { "message": "My Provider" }, @@ -7642,5 +7645,38 @@ }, "items": { "message": "Items" + }, + "assignedSeats": { + "message": "Assigned seats" + }, + "assigned": { + "message": "Assigned" + }, + "used": { + "message": "Used" + }, + "remaining": { + "message": "Remaining" + }, + "unlinkOrganization": { + "message": "Unlink organization" + }, + "manageSeats": { + "message": "MANAGE SEATS" + }, + "manageSeatsDescription": { + "message": "Adjustments to seats will be reflected in the next billing cycle." + }, + "unassignedSeatsDescription": { + "message": "Unassigned subscription seats" + }, + "purchaseSeatDescription": { + "message": "Additional seats purchased" + }, + "assignedSeatCannotUpdate": { + "message": "Assigned Seats can not be updated. Please contact your organization owner for assistance." + }, + "subscriptionUpdateFailed": { + "message": "Subscription update failed" } } diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index 9478c628c01..d70d6cff23d 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index 9040d7714a1..561d267cab8 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 92c4329e015..d3a9ad414a4 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 30baca8b3d3..250614565da 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index c67fefe6057..1866c649e41 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index f1d508c1029..0c35039424c 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index 86f441e0be5..2c3d10c6a81 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index a4a45076d6d..43bbf01847a 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Et voi lisätä itseäsi kokoelmiin." + }, + "assign": { + "message": "Määritä" + }, + "assignToCollections": { + "message": "Määritä kokoelmiin" + }, + "assignToTheseCollections": { + "message": "Määritä näihin kokoelmiin" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Valitse kokoelmat, joihin kohteet jaetaan. Kun kohdetta muokataan yhdessä kokoelmassa, päivittyy muutos kaikkiin kokoelmiin. Kohteet näkyvät vain niille organisaation jäsenille, joilla on näiden kokoelmien käyttöoikeus." + }, + "selectCollectionsToAssign": { + "message": "Valitse määritettävät kokoelmat" + }, + "noCollectionsAssigned": { + "message": "Kokoelmia ei ole määritetty" + }, + "successfullyAssignedCollections": { + "message": "Kokoelmat on määritetty" + }, + "bulkCollectionAssignmentWarning": { + "message": "Olet valinnut $TOTAL_COUNT$ kohdetta. Et voi muuttaa näistä $READONLY_COUNT$ kohdetta, koska käyttöoikeutesi eivät riitä niiden muokkaukseen.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Kohteet" } } diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index 4c1ca9787a1..bfcdccc7a2e 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index 271c1d3b2e7..9cc40e4133d 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Vous ne pouvez vous ajoutez vous-même aux collections." + }, + "assign": { + "message": "Assigner" + }, + "assignToCollections": { + "message": "Assigner aux collections" + }, + "assignToTheseCollections": { + "message": "Assigner à ces collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Sélectionnez les collections avec lesquelles les éléments seront partagés. Une fois qu'un élément est mis à jour dans une collection, il le sera aussi dans toutes ces collections. Seuls les membres de l'organisation ayant accès à ces collections pourront voir les éléments." + }, + "selectCollectionsToAssign": { + "message": "Sélectionnez les collections à assigner" + }, + "noCollectionsAssigned": { + "message": "Aucune collection n'a été assignée" + }, + "successfullyAssignedCollections": { + "message": "Collections assignées avec succès" + }, + "bulkCollectionAssignmentWarning": { + "message": "Vous avez sélectionné $TOTAL_COUNT$ éléments. Vous ne pouvez pas mettre à jour $READONLY_COUNT$ de ces éléments parce que vous n'avez pas les autorisations pour les éditer.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Éléments" } } diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index 0562f4bf84a..ad51cf605f5 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 2f82b1b71d8..0c8316fe49a 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index a583f273b58..b00c912d7ac 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index 446e5115176..4d47c12e869 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index 0540b6e6ba6..e1f3682bbd8 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Nem adhadjuk magunkat a gyűjteményhez." + }, + "assign": { + "message": "Hozzárendelés" + }, + "assignToCollections": { + "message": "Hozzárendelés gyűjteményekhez" + }, + "assignToTheseCollections": { + "message": "Hozzárendelés ezen gyűjteményekhez" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Válasszuk ki azokat a gyűjteményeket, amelyekkel az elemek megosztásra kerülnek. Ha egy elem egy gyűjteményben frissítésre kerül, az az összes gyűjteményben megjelenik. Csak az ezekhez a gyűjteményekhez hozzáféréssel rendelkező szervezeti tagok láthatják az elemeket." + }, + "selectCollectionsToAssign": { + "message": "Hozzárendelendő gyűjtemények kiválasztása" + }, + "noCollectionsAssigned": { + "message": "Nem lettek gyűjtemények hozzárendelve." + }, + "successfullyAssignedCollections": { + "message": "A gyűjtemények sikeresen hozzárendelésre kerültek." + }, + "bulkCollectionAssignmentWarning": { + "message": "$TOTAL_COUNT$ elem lett kiválasztva. Az elemek közül $READONLY_COUNT$ nem frissíthető, mert nincs szerkesztési jogosultság.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Elemek" } } diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index 6ea4a4d7b30..83d6c426737 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index 7f9509e8d2d..c31ff1a5a26 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Non puoi aggiungerti da solo alle raccolte." + }, + "assign": { + "message": "Assegna" + }, + "assignToCollections": { + "message": "Assegna alle raccolte" + }, + "assignToTheseCollections": { + "message": "Assegna a queste raccolte" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Seleziona le raccolte con cui questi elementi saranno condivisi. Una volta un elemento è aggiornato in una raccolta, la modifica si rifletterà in tutte le raccolte. Solo i membri dell'organizzazione con accesso a queste raccolte potranno visualizzare gli elementi." + }, + "selectCollectionsToAssign": { + "message": "Seleziona le raccolte da assegnare" + }, + "noCollectionsAssigned": { + "message": "Nessuna raccolta è stata assegnata" + }, + "successfullyAssignedCollections": { + "message": "Raccolte assegnate" + }, + "bulkCollectionAssignmentWarning": { + "message": "Hai selezionato $TOTAL_COUNT$ elementi. Non puoi aggiornare $READONLY_COUNT$ elementi perché non hai l'autorizzazione per modificarli.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Elementi" } } diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index 95333eebb0b..c2e3e77b241 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "コレクションに自分自身を追加することはできません。" + }, + "assign": { + "message": "割り当て" + }, + "assignToCollections": { + "message": "コレクションに割り当てる" + }, + "assignToTheseCollections": { + "message": "これらのコレクションに割り当てる" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "アイテムを共有するコレクションを選択します。1つのコレクションでアイテムが更新されると、すべてのコレクションに反映されます。これらのコレクションにアクセスできる組織メンバーだけがアイテムを見ることができます。" + }, + "selectCollectionsToAssign": { + "message": "割り当てるコレクションを選択" + }, + "noCollectionsAssigned": { + "message": "コレクションが割り当てられていません" + }, + "successfullyAssignedCollections": { + "message": "コレクションの割り当てに成功しました" + }, + "bulkCollectionAssignmentWarning": { + "message": "$TOTAL_COUNT$ アイテムを選択しました。編集権限がないため、$READONLY_COUNT$ アイテムを更新できません。", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "アイテム" } } diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index ed27946a587..e2883119565 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index 0562f4bf84a..ad51cf605f5 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index 48546d43733..02631198e3f 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index c2462146c3c..1867603a0d9 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index 48b9c7abb1a..8c2df3012a3 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Tu nevari sevi pievienot krājumiem." + }, + "assign": { + "message": "Piešķirt" + }, + "assignToCollections": { + "message": "Piešķirt krājumiem" + }, + "assignToTheseCollections": { + "message": "Piešķirt šiem krājumiem" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Jāatlasa krājumi, ar kuriem vienumi tiks kopīgoti. Tiklīdz kāds vienums tiks atjaunināts vienā krājumā, tas atspoguļosies visos pārējos. Tikai apvienības dalībnieki ar piekļuvi šiem krājumiem varēs redzēt vienumus." + }, + "selectCollectionsToAssign": { + "message": "Atlasīt krājumus, lai piešķirtu" + }, + "noCollectionsAssigned": { + "message": "Neviens krājums nav piešķirts" + }, + "successfullyAssignedCollections": { + "message": "Krājumi veiksmīgi piešķirti" + }, + "bulkCollectionAssignmentWarning": { + "message": "Ir atlasīti $TOTAL_COUNT$ vienumi. Nevar atjaunināt $READONLY_COUNT$ no vienumiem, jo trūkst labošanas atļaujas.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Vienumi" } } diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index 270db2fa071..81b65297939 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index 0562f4bf84a..ad51cf605f5 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index 0562f4bf84a..ad51cf605f5 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index babffc6d3c9..8840ef7c854 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index 68fc8b2774d..a42b9d71af9 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index 21db16911fd..ecdde73f68f 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -579,7 +579,7 @@ "message": "Toegang" }, "accessLevel": { - "message": "Access level" + "message": "Toegangsniveau" }, "loggedOut": { "message": "Uitgelogd" @@ -660,13 +660,13 @@ "message": "Geef je passkey een naam om deze later te herkennen." }, "useForVaultEncryption": { - "message": "Use for vault encryption" + "message": "Gebruik voor kluis versleuteling" }, "useForVaultEncryptionInfo": { - "message": "Log in and unlock on supported devices without your master password. Follow the prompts from your browser to finalize setup." + "message": "Meld aan en ontgrendel op ondersteunde apparaten zonder uw hoofdwachtwoord. Volg de aanwijzingen in uw browser om dit te activeren." }, "useForVaultEncryptionErrorReadingPasskey": { - "message": "Error reading passkey. Try again or uncheck this option." + "message": "Fout bij lezen van passkey. Probeer het opnieuw of selecteer een andere optie." }, "encryptionNotSupported": { "message": "Encryptie niet ondersteund" @@ -2036,7 +2036,7 @@ "message": "1 GB versleutelde opslag voor bijlagen." }, "premiumSignUpTwoStepOptions": { - "message": "Proprietary two-step login options such as YubiKey and Duo." + "message": "Gepatenteerde 2 stap login opties zoals YubiKey en Duo." }, "premiumSignUpEmergency": { "message": "Noodtoegang" @@ -3621,7 +3621,7 @@ "message": "Encryptiesleutel bijwerken" }, "updateEncryptionSchemeDesc": { - "message": "We've changed the encryption scheme to provide better security. Update your encryption key now by entering your master password below." + "message": "Wij hebben de versleutelings- methode aangepast om betere beveiliging te kunnen leveren. Voer uw hoofdwachtwoord in om dit door te voeren." }, "updateEncryptionKeyWarning": { "message": "Na het bijwerken van je encryptiesleutel moet je je afmelden en weer aanmelden bij alle Bitwarden-applicaties die je gebruikt (zoals de mobiele app of browserextensies). Als je niet opnieuw inlogt (wat je nieuwe encryptiesleutel downloadt), kan dit gegevensbeschadiging tot gevolg hebben. We proberen je automatisch uit te loggen, maar het kan zijn dat dit met enige vertraging gebeurt." @@ -3781,7 +3781,7 @@ "message": "Dit item heeft oude bestandsbijlagen die aangepast moeten worden." }, "attachmentFixDescription": { - "message": "This attachment uses outdated encryption. Select 'Fix' to download, re-encrypt, and re-upload the attachment." + "message": "Deze bijlage maakt gebruik van verouderde versleuteling. Selecteer \"oplossen\" om het bestand te downloaden, opnieuw te versleutelen en vervolgens opnieuw te uploaden." }, "fix": { "message": "Oplossen", @@ -4044,10 +4044,10 @@ "message": "Je kunt dit tabblad nu sluiten en doorgaan in de extensie." }, "youSuccessfullyLoggedIn": { - "message": "You successfully logged in" + "message": "U bent succesvol ingelogd" }, "thisWindowWillCloseIn5Seconds": { - "message": "This window will automatically close in 5 seconds" + "message": "Dit scherm sluit automatisch over 5 seconden" }, "includeAllTeamsFeatures": { "message": "Alle functionaliteit van Teams plus:" @@ -4776,13 +4776,13 @@ "message": "Accountherstel-administratie" }, "accountRecoveryPolicyDesc": { - "message": "Based on the encryption method, recover accounts when master passwords or trusted devices are forgotten or lost." + "message": "Gebaseerd op de huidige versleutelings- methode, herstel accounts wanneer hoofdwachtwoorden of vertrouwde apparaten vergeten of vermist zijn." }, "accountRecoveryPolicyWarning": { - "message": "Existing accounts with master passwords will require members to self-enroll before administrators can recover their accounts. Automatic enrollment will turn on account recovery for new members." + "message": "Bestaande accounts met hoofdwachtwoorden vereisen gebruikers om zelf in te schrijven voordat administratoren hun accounts kunnen herstellen. Automatische inschrijving zal automatisch account herstel inschakelen voor nieuwe gebruikers." }, "accountRecoverySingleOrgRequirementDesc": { - "message": "The single organization Enterprise policy must be turned on before activating this policy." + "message": "Het enkele organisatie beleid moet aangezet zijn voordat dit beleid geactiveerd kan worden." }, "resetPasswordPolicyAutoEnroll": { "message": "Automatische inschrijving" @@ -5114,7 +5114,7 @@ "message": "Automatisch invullen activeren" }, "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "message": "Activeer de automatisch invullen wanneer de pagina geladen is instelling in de browser extensie voor bestaande en nieuwe gebruikers." }, "experimentalFeature": { "message": "Gehackte of onbetrouwbare websites kunnen automatisch invullen bij laden van pagina misbruiken." @@ -5412,7 +5412,7 @@ "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Use the require single-sign-on authentication policy to require all members to log in with SSO.'" }, "ssoPolicyHelpAnchor": { - "message": "require single sign-on authentication policy", + "message": "vereis single sign-on authenticatie beleid", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Use the require single-sign-on authentication policy to require all members to log in with SSO.'" }, "ssoPolicyHelpEnd": { @@ -5429,15 +5429,15 @@ "message": "Key Connector" }, "memberDecryptionKeyConnectorDescStart": { - "message": "Connect login with SSO to your self-hosted decryption key server. Using this option, members won’t need to use their master passwords to decrypt vault data. The", + "message": "Verbind inloggen met SSO naar je zelf beheerde Decoderingsserver. Door deze optie te gebruiken hoeven gebruikers niet hun hoofdwachtwoord te gebruiken om kluis data te decoderen. Het", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Connect login with SSO to your self-hosted decryption key server. Using this option, members won’t need to use their master passwords to decrypt vault data. The require SSO authentication and single organization policies are required to set up Key Connector decryption. Contact Bitwarden Support for set up assistance.'" }, "memberDecryptionKeyConnectorDescLink": { - "message": "require SSO authentication and single organization policies", + "message": "vereis het SSO authenticatie beleid en het enkele organisatie beleid", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Connect login with SSO to your self-hosted decryption key server. Using this option, members won’t need to use their master passwords to decrypt vault data. The require SSO authentication and single organization policies are required to set up Key Connector decryption. Contact Bitwarden Support for set up assistance.'" }, "memberDecryptionKeyConnectorDescEnd": { - "message": "are required to set up Key Connector decryption. Contact Bitwarden Support for set up assistance.", + "message": "is vereist om Key Connector decryptie in te stellen. Contacteer Bitwarden support voor assistentie.", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Connect login with SSO to your self-hosted decryption key server. Using this option, members won’t need to use their master passwords to decrypt vault data. The require SSO authentication and single organization policies are required to set up Key Connector decryption. Contact Bitwarden Support for set up assistance.'" }, "keyConnectorPolicyRestriction": { @@ -6197,7 +6197,7 @@ } }, "deleteServiceAccountToast": { - "message": "Service account deleted" + "message": "Service account verwijderd" }, "deleteServiceAccountsToast": { "message": "Serviceaccounts verwijderd" @@ -6734,7 +6734,7 @@ } }, "teamsStarterPlanInvLimitReachedManageBilling": { - "message": "Teams Starter plans may have up to $SEATCOUNT$ members. Upgrade to your plan to invite more members.", + "message": "Gratis organisaties beschikken maximaal over $SEATCOUNT$ leden. Upgrade je abonnement om meer leden uit te kunnen nodigen.", "placeholders": { "seatcount": { "content": "$1", @@ -6881,7 +6881,7 @@ "message": "Werk je versleutelingsinstellingen bij om aan de nieuwe beveiligingsaanbevelingen te voldoen en de bescherming van je account te verbeteren." }, "changeKdfLoggedOutWarning": { - "message": "Proceeding will log you out of all active sessions. You will need to log back in and complete two-step login setup. We recommend exporting your vault before changing your encryption settings to prevent data loss." + "message": "Als u doorgaat zullen al uw actieve sessies word uitgelogd. U zult zich opnieuw aan moeten melden en 2 stappen verificatie moeten instellen. Wij raden u aan om uw gegevens eerst te exporteren voordat u uw encryptie instellingen aanpast om data verlies te voorkomen." }, "secretsManager": { "message": "Secrets Manager" @@ -7020,10 +7020,10 @@ "message": "Gelekt hoofdwachtwoord" }, "exposedMasterPasswordDesc": { - "message": "Password found in a data breach. Use a unique password to protect your account. Are you sure you want to use an exposed password?" + "message": "Dit wachtwoord is gevonden in een datalek. Gebruik een uniek wachtwoord om je account te beveiligen. Weet je zeker dat je een gelekt wachtwoord wil gebruiken?" }, "weakAndExposedMasterPassword": { - "message": "Weak and Exposed Master Password" + "message": "Zwak en gelekt hoofdwachtwoord" }, "weakAndBreachedMasterPasswordDesc": { "message": "Zwak wachtwoord geïdentificeerd en gevonden in een datalek. Gebruik een sterk en uniek wachtwoord om je account te beschermen. Weet je zeker dat je dit wachtwoord wilt gebruiken?" @@ -7271,7 +7271,7 @@ "message": "Volgende" }, "ssoLoginIsRequired": { - "message": "SSO login is required" + "message": "SSO login is vereist" }, "selectedRegionFlag": { "message": "Geselecteerde regionale vlag" @@ -7423,7 +7423,7 @@ "message": "Service account limiet (optioneel)" }, "maxServiceAccountCost": { - "message": "Max potential service account cost" + "message": "Maximale potentiële service account kosten" }, "loggedInExclamation": { "message": "Ingelogd!" @@ -7444,7 +7444,7 @@ "message": "Aliasdomein" }, "alreadyHaveAccount": { - "message": "Already have an account?" + "message": "Heb je al een account?" }, "skipToContent": { "message": "Ga naar de inhoud" @@ -7471,7 +7471,7 @@ } }, "seeDetailedInstructions": { - "message": "See detailed instructions on our help site at", + "message": "Zie gedetailleerde instructies op onze hulp pagina hier", "description": "This is followed a by a hyperlink to the help website." }, "installBrowserExtension": { @@ -7487,13 +7487,13 @@ "message": "Er is een onverwachte fout opgetreden tijdens het laden van deze Send. Probeer het later opnieuw." }, "seatLimitReached": { - "message": "Seat limit has been reached" + "message": "Gebruikers limiet is bereikt" }, "contactYourProvider": { - "message": "Contact your provider to purchase additional seats." + "message": "Contacteer uw provider om aanvullende licenties aan te schaffen." }, "seatLimitReachedContactYourProvider": { - "message": "Seat limit has been reached. Contact your provider to purchase additional seats." + "message": "De limiet voor het aantal gebruikers is bereikt. Contacteer je provider om aanvullende licenties aan te schaffen." }, "collectionAccessRestricted": { "message": "Collectietoegang is beperkt" @@ -7511,7 +7511,7 @@ "message": "Toegang tot serviceaccount bijgewerkt" }, "commonImportFormats": { - "message": "Common formats", + "message": "Gangbare formaten", "description": "Label indicating the most common import formats" }, "maintainYourSubscription": { @@ -7601,9 +7601,46 @@ "message": "Providerportaal" }, "restrictedGroupAccess": { - "message": "You cannot add yourself to groups." + "message": "Het is niet mogelijk om jezelf toe te voegen aan groepen." }, "restrictedCollectionAccess": { - "message": "You cannot add yourself to collections." + "message": "Het is niet mogelijk om jezelf toe te voegen aan collecties." + }, + "assign": { + "message": "Toewijzen" + }, + "assignToCollections": { + "message": "Toewijzen aan collecties" + }, + "assignToTheseCollections": { + "message": "Aan deze collecties toewijzen" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Selecteer de collecies om de items mee te delen. Zodra een item in een collectie is bijgewerkt, werkt dat door in alle collecties. Alleen organisatieleden met toegang tot deze collecties kunnen de items zien." + }, + "selectCollectionsToAssign": { + "message": "Collecties voor toewijzen selecteren" + }, + "noCollectionsAssigned": { + "message": "Er zijn geen collecties toegewezen" + }, + "successfullyAssignedCollections": { + "message": "Succesvol toegewezen collecties" + }, + "bulkCollectionAssignmentWarning": { + "message": "Je hebt $TOTAL_COUNT$ items geselecteerd. Je kunt $READONLY_COUNT$ items niet bijwerken omdat je geen bewerkrechten hebt.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index 888c7b1b9c0..7ac2fff5cef 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index 0562f4bf84a..ad51cf605f5 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index f8d93743447..08d67480239 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Przypisz" + }, + "assignToCollections": { + "message": "Przypisz do kolekcji" + }, + "assignToTheseCollections": { + "message": "Przypisz do tych kolekcji" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Wybierz kolekcje, z którymi elementy będą udostępniane. Gdy element zostanie zaktualizowany w jednej kolekcji, zostanie to odzwierciedlone we wszystkich kolekcjach. Tylko członkowie organizacji z dostępem do tych kolekcji będą mogli zobaczyć te elementy." + }, + "selectCollectionsToAssign": { + "message": "Wybierz kolekcje do przypisania" + }, + "noCollectionsAssigned": { + "message": "Nie przypisano kolekcji" + }, + "successfullyAssignedCollections": { + "message": "Pomyślnie przypisano kolekcje" + }, + "bulkCollectionAssignmentWarning": { + "message": "Wybrałeś $TOTAL_COUNT$ elementów. Nie możesz zaktualizować $READONLY_COUNT$ elementów, ponieważ nie masz uprawnień do edycji.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Elementy" } } diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 402697de2bd..5ff05ed37e8 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Você não pode se adicionar às coleções." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index 0d163c9f199..0c8c809ffc2 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Não se pode adicionar a si próprio a coleções." + }, + "assign": { + "message": "Atribuir" + }, + "assignToCollections": { + "message": "Atribuir às coleções" + }, + "assignToTheseCollections": { + "message": "Atribuir a estas coleções" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Selecione as coleções com as quais os itens serão partilhados. Assim que um item for atualizado numa coleção, será refletido em todas as coleções. Apenas os membros da organização com acesso a estas coleções poderão ver os itens." + }, + "selectCollectionsToAssign": { + "message": "Selecione as coleções a atribuir" + }, + "noCollectionsAssigned": { + "message": "Não foram atribuídas quaisquer coleções" + }, + "successfullyAssignedCollections": { + "message": "Coleções atribuídas com sucesso" + }, + "bulkCollectionAssignmentWarning": { + "message": "Selecionou $TOTAL_COUNT$ itens. Não pode atualizar $READONLY_COUNT$ dos itens porque não tem permissões de edição.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Itens" } } diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index 1d92eccc00f..244395daaae 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index b5b66777f84..b68b045bbbe 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Нельзя добавить самого себя в коллекции." + }, + "assign": { + "message": "Назначить" + }, + "assignToCollections": { + "message": "Назначить коллекциям" + }, + "assignToTheseCollections": { + "message": "Назначить этим коллекциям" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Выберите коллекции, в которые будут переданы элементы. Если элемент обновлен в одной коллекции, это изменение будет отражено во всех коллекциях. Только члены организации, имеющие доступ к этим коллекциям, смогут видеть элементы." + }, + "selectCollectionsToAssign": { + "message": "Выбрать коллекции для назначения" + }, + "noCollectionsAssigned": { + "message": "Коллекции не назначены" + }, + "successfullyAssignedCollections": { + "message": "Коллекции успешно назначены" + }, + "bulkCollectionAssignmentWarning": { + "message": "Вы выбрали $TOTAL_COUNT$ элемента(-ов). Вы не можете обновить $READONLY_COUNT$ элемента(-ов), поскольку у вас нет прав на редактирование.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Элементы" } } diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index 92b3d556658..ce4df3eeda3 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index ef8c8ad20c4..74f68fc0cc2 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Seba nemôžete pridať do zbierok." + }, + "assign": { + "message": "Prideliť" + }, + "assignToCollections": { + "message": "Prideliť k zbierkam" + }, + "assignToTheseCollections": { + "message": "Prideliť k týmto zbierkam" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Vyberte zbierky s ktorými budú položky zdieľané. Zmeny položky v jednej zbierke sa prejavia vo všetkých zbierkach. Iba členovia organizácie s prístupom k týmto zbierkam budu položky vidieť." + }, + "selectCollectionsToAssign": { + "message": "Vyberte zbierky na pridelenie" + }, + "noCollectionsAssigned": { + "message": "Neboli pridelené žiadne zbierky" + }, + "successfullyAssignedCollections": { + "message": "Úspešne pridelené zbierky" + }, + "bulkCollectionAssignmentWarning": { + "message": "Vybrali ste $TOTAL_COUNT$ položiek. Nemôžete aktualizovať $READONLY_COUNT$ položiek, pretože nemáte oprávnenie na úpravu.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Položky" } } diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index 0dc844a469b..4982120003c 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/sr/messages.json b/apps/web/src/locales/sr/messages.json index 0d57790be7a..f5fe041433f 100644 --- a/apps/web/src/locales/sr/messages.json +++ b/apps/web/src/locales/sr/messages.json @@ -579,7 +579,7 @@ "message": "Приступ" }, "accessLevel": { - "message": "Access level" + "message": "Ниво приступа" }, "loggedOut": { "message": "Одјављено" @@ -7601,9 +7601,46 @@ "message": "Портал провајдера" }, "restrictedGroupAccess": { - "message": "You cannot add yourself to groups." + "message": "Не можете да се додате у групе." }, "restrictedCollectionAccess": { - "message": "You cannot add yourself to collections." + "message": "Не можете да се додате у колекције." + }, + "assign": { + "message": "Додели" + }, + "assignToCollections": { + "message": "Додели колекцијама" + }, + "assignToTheseCollections": { + "message": "Додели овим колекцијама" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Изаберите колекције са којима ће се ставке делити. Када се ставка ажурира у једној колекцији, она ће се одразити на све колекције. Само чланови организације са приступом овим колекцијама ће моћи да виде ставке." + }, + "selectCollectionsToAssign": { + "message": "Изаберите колекције за доделу" + }, + "noCollectionsAssigned": { + "message": "Није додељена ниједна колекција" + }, + "successfullyAssignedCollections": { + "message": "Успешно додељене колекције" + }, + "bulkCollectionAssignmentWarning": { + "message": "Одабрали сте $TOTAL_COUNT$ ставки. Не можете да ажурирате $READONLY_COUNT$ од ставки јер немате дозволе за уређивање.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Ставке" } } diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index ef52db983ec..4553f85c33d 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index 04ca471923f..8765baaa3bc 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Du kan inte lägga till dig själv i samlingar." + }, + "assign": { + "message": "Tilldela" + }, + "assignToCollections": { + "message": "Tilldela till samlingar" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Välj samlingar att tilldela" + }, + "noCollectionsAssigned": { + "message": "Inga samlingar har tilldelats" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Objekt" } } diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index 0562f4bf84a..ad51cf605f5 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index e68a55564ed..69ee59eb260 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index 46458c966a2..fb626b82f52 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Kendinizi koleksiyonlara ekleyemezsiniz." + }, + "assign": { + "message": "Ata" + }, + "assignToCollections": { + "message": "Koleksiyonlara ata" + }, + "assignToTheseCollections": { + "message": "Bu koleksiyonlara ata" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Öğelerin paylaşılacağı koleksiyonları seçin. Bir koleksiyondaki bir öğe güncellendiğinde tüm koleksiyonlara yansıtılacaktır. Öğeleri yalnızca bu koleksiyonlara erişimi olan kuruluş üyeleri görebilir." + }, + "selectCollectionsToAssign": { + "message": "Atanacak koleksiyonları seçin" + }, + "noCollectionsAssigned": { + "message": "Hiçbir koleksiyon atanmadı" + }, + "successfullyAssignedCollections": { + "message": "Başarıyla atanan koleksiyonlar" + }, + "bulkCollectionAssignmentWarning": { + "message": "$TOTAL_COUNT$ öğe seçtiniz. Düzenleme izniniz olmadığı için $READONLY_COUNT$ öğeyi güncelleyemezsiniz.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Ögeler" } } diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index 502a76066ea..e2d2e6163c8 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "Ви не можете додати себе до збірок." + }, + "assign": { + "message": "Призначити" + }, + "assignToCollections": { + "message": "Призначити до збірок" + }, + "assignToTheseCollections": { + "message": "Призначити до цих збірок" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Оберіть збірки, в яких поширюватимуться записи. Після оновлення запису в одній збірці зміни буде відображено у всіх збірках. Лише учасники організації з доступом до цих збірок зможуть переглядати записи." + }, + "selectCollectionsToAssign": { + "message": "Оберіть збірки для призначення" + }, + "noCollectionsAssigned": { + "message": "Не призначено жодної збірки" + }, + "successfullyAssignedCollections": { + "message": "Збірки успішно призначено" + }, + "bulkCollectionAssignmentWarning": { + "message": "Ви вибрали $TOTAL_COUNT$ записів. Ви не можете оновити $READONLY_COUNT$ записів, тому що у вас немає доступу на редагування.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Записи" } } diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index d4202225a4c..316704ffdf4 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 7e52bc1f447..e7668f85d59 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -7478,7 +7478,7 @@ "message": "安装浏览器扩展" }, "installBrowserExtensionDetails": { - "message": "使用扩展快速保存登录信息和自动填充表单,而无需打开网页应用程序。" + "message": "使用扩展快速保存登录信息和自动填充表单,而无需打开网页 App。" }, "projectAccessUpdated": { "message": "工程访问权限已更新" @@ -7511,7 +7511,7 @@ "message": "服务账户访问权限已更新" }, "commonImportFormats": { - "message": "通用格式", + "message": "常规格式", "description": "Label indicating the most common import formats" }, "maintainYourSubscription": { @@ -7533,7 +7533,7 @@ "description": "This describes new features and improvements for user roles and collections" }, "collectionEnhancementsLearnMore": { - "message": "了解更多关于集合管理" + "message": "了解更多关于集合管理的信息" }, "organizationInformation": { "message": "组织信息" @@ -7604,6 +7604,43 @@ "message": "您不能将自己添加到群组。" }, "restrictedCollectionAccess": { - "message": "您不能将自己添加到群组。" + "message": "您不能将自己添加到集合。" + }, + "assign": { + "message": "分配" + }, + "assignToCollections": { + "message": "分配到集合" + }, + "assignToTheseCollections": { + "message": "分配到这些集合" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "选择要共享项目的集合。当一个项目在某个集合中更新后,它将反映在所有集合中。只有能够访问这些集合的组织成员才能看到此项目。" + }, + "selectCollectionsToAssign": { + "message": "选择要分配的集合" + }, + "noCollectionsAssigned": { + "message": "没有分配任何集合" + }, + "successfullyAssignedCollections": { + "message": "成功分配了集合" + }, + "bulkCollectionAssignmentWarning": { + "message": "您选择了 $TOTAL_COUNT$ 个项目。其中的 $READONLY_COUNT$ 个项目由于您没有编辑权限,您将无法更新它们。", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "项目" } } diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index c90de26bd24..0fa0838f2df 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -7605,5 +7605,42 @@ }, "restrictedCollectionAccess": { "message": "You cannot add yourself to collections." + }, + "assign": { + "message": "Assign" + }, + "assignToCollections": { + "message": "Assign to collections" + }, + "assignToTheseCollections": { + "message": "Assign to these collections" + }, + "bulkCollectionAssignmentDialogDescription": { + "message": "Select the collections that the items will be shared with. Once an item is updated in one collection, it will be reflected in all collections. Only organization members with access to these collections will be able to see the items." + }, + "selectCollectionsToAssign": { + "message": "Select collections to assign" + }, + "noCollectionsAssigned": { + "message": "No collections have been assigned" + }, + "successfullyAssignedCollections": { + "message": "Successfully assigned collections" + }, + "bulkCollectionAssignmentWarning": { + "message": "You have selected $TOTAL_COUNT$ items. You cannot update $READONLY_COUNT$ of the items because you do not have edit permissions.", + "placeholders": { + "total_count": { + "content": "$1", + "example": "10" + }, + "readonly_count": { + "content": "$2", + "example": "3" + } + } + }, + "items": { + "message": "Items" } } diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index f088aadb756..815a8aff9e3 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -194,39 +194,44 @@ const devServer = }, }, // host: '192.168.1.9', - proxy: { - "/api": { + proxy: [ + { + context: ["/api"], target: envConfig.dev?.proxyApi, pathRewrite: { "^/api": "" }, secure: false, changeOrigin: true, }, - "/identity": { + { + context: ["/identity"], target: envConfig.dev?.proxyIdentity, pathRewrite: { "^/identity": "" }, secure: false, changeOrigin: true, }, - "/events": { + { + context: ["/events"], target: envConfig.dev?.proxyEvents, pathRewrite: { "^/events": "" }, secure: false, changeOrigin: true, }, - "/notifications": { + { + context: ["/notifications"], target: envConfig.dev?.proxyNotifications, pathRewrite: { "^/notifications": "" }, secure: false, changeOrigin: true, ws: true, }, - "/icons": { + { + context: ["/icons"], target: envConfig.dev?.proxyIcons, pathRewrite: { "^/icons": "" }, secure: false, changeOrigin: true, }, - }, + ], headers: (req) => { if (!req.originalUrl.includes("connector.html")) { return { diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.ts index a22de64c391..8e8db457e50 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/scim.component.ts @@ -107,7 +107,7 @@ export class ScimComponent implements OnInit { try { const response = await this.rotatePromise; this.formData.setValue({ - endpointUrl: this.getScimEndpointUrl(), + endpointUrl: await this.getScimEndpointUrl(), clientSecret: response.apiKey, }); this.platformUtilsService.showToast("success", null, this.i18nService.t("scimApiKeyRotated")); diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts index dc3dea3c9dd..20e98ce0842 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; @@ -13,6 +13,8 @@ import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; import { PlanType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -50,8 +52,14 @@ export class ClientsComponent implements OnInit { protected actionPromise: Promise; private pagedClientsCount = 0; + protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$( + FeatureFlag.EnableConsolidatedBilling, + false, + ); + constructor( private route: ActivatedRoute, + private router: Router, private providerService: ProviderService, private apiService: ApiService, private searchService: SearchService, @@ -64,20 +72,29 @@ export class ClientsComponent implements OnInit { private organizationService: OrganizationService, private organizationApiService: OrganizationApiServiceAbstraction, private dialogService: DialogService, + private configService: ConfigService, ) {} async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent.params.subscribe(async (params) => { - this.providerId = params.providerId; - await this.load(); + const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$); - /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - this.searchText = qParams.search; + if (enableConsolidatedBilling) { + await this.router.navigate(["../manage-client-organizations"], { relativeTo: this.route }); + } else { + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe + this.route.parent.params.subscribe(async (params) => { + this.providerId = params.providerId; + + await this.load(); + + /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ + this.route.queryParams.pipe(first()).subscribe(async (qParams) => { + this.searchText = qParams.search; + }); }); - }); + } } async load() { 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 333ea66e26c..fe7f051652a 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 @@ -4,7 +4,11 @@ - + {{ "loading" | i18n }} - +

{{ "ssoPolicyHelpStart" | i18n }} {{ "ssoPolicyHelpAnchor" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts index d5a1aebdd80..45cfc02a095 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts @@ -26,7 +26,7 @@ import { SsoConfigApi } from "@bitwarden/common/auth/models/api/sso-config.api"; import { OrganizationSsoRequest } from "@bitwarden/common/auth/models/request/organization-sso.request"; import { OrganizationSsoResponse } from "@bitwarden/common/auth/models/response/organization-sso.response"; import { SsoConfigView } from "@bitwarden/common/auth/models/view/sso-config.view"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -186,7 +186,7 @@ export class SsoComponent implements OnInit, OnDestroy { private i18nService: I18nService, private organizationService: OrganizationService, private organizationApiService: OrganizationApiServiceAbstraction, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, ) {} async ngOnInit() { diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html new file mode 100644 index 00000000000..18d6b3e63cc --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html @@ -0,0 +1,49 @@ + + + {{ "manageSeats" | i18n }} + {{ clientName }} + +

+

+ {{ "manageSeatsDescription" | i18n }} +

+ + + {{ "assignedSeats" | i18n }} + + + + +

+ {{ unassignedSeats }} + {{ "unassignedSeatsDescription" | i18n }} +

+

+ {{ AdditionalSeatPurchased }} + {{ "purchaseSeatDescription" | i18n }} +

+
+
+ + + + + diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts new file mode 100644 index 00000000000..2c8d59edc34 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts @@ -0,0 +1,115 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; + +import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; +import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; +import { ProviderSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/provider-subscription-update.request"; +import { Plans } from "@bitwarden/common/billing/models/response/provider-subscription-response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; + +type ManageClientOrganizationDialogParams = { + organization: ProviderOrganizationOrganizationDetailsResponse; +}; + +@Component({ + templateUrl: "manage-client-organization-subscription.component.html", +}) +// eslint-disable-next-line rxjs-angular/prefer-takeuntil +export class ManageClientOrganizationSubscriptionComponent implements OnInit { + loading = true; + providerOrganizationId: string; + providerId: string; + + clientName: string; + assignedSeats: number; + unassignedSeats: number; + planName: string; + AdditionalSeatPurchased: number; + remainingOpenSeats: number; + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) protected data: ManageClientOrganizationDialogParams, + private billingApiService: BillingApiService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + ) { + this.providerOrganizationId = data.organization.id; + this.providerId = data.organization.providerId; + this.clientName = data.organization.organizationName; + this.assignedSeats = data.organization.seats; + this.planName = data.organization.plan; + } + + async ngOnInit() { + try { + const response = await this.billingApiService.getProviderClientSubscriptions(this.providerId); + this.AdditionalSeatPurchased = this.getPurchasedSeatsByPlan(this.planName, response.plans); + const seatMinimum = this.getProviderSeatMinimumByPlan(this.planName, response.plans); + const assignedByPlan = this.getAssignedByPlan(this.planName, response.plans); + this.remainingOpenSeats = seatMinimum - assignedByPlan; + this.unassignedSeats = Math.abs(this.remainingOpenSeats); + } catch (error) { + this.remainingOpenSeats = 0; + this.AdditionalSeatPurchased = 0; + } + this.loading = false; + } + + async updateSubscription(assignedSeats: number) { + this.loading = true; + if (!assignedSeats) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("assignedSeatCannotUpdate"), + ); + return; + } + + const request = new ProviderSubscriptionUpdateRequest(); + request.assignedSeats = assignedSeats; + + await this.billingApiService.putProviderClientSubscriptions( + this.providerId, + this.providerOrganizationId, + request, + ); + this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated")); + this.loading = false; + this.dialogRef.close(); + } + + getPurchasedSeatsByPlan(planName: string, plans: Plans[]): number { + const plan = plans.find((plan) => plan.planName === planName); + if (plan) { + return plan.purchasedSeats; + } else { + return 0; + } + } + + getAssignedByPlan(planName: string, plans: Plans[]): number { + const plan = plans.find((plan) => plan.planName === planName); + if (plan) { + return plan.assignedSeats; + } else { + return 0; + } + } + + getProviderSeatMinimumByPlan(planName: string, plans: Plans[]) { + const plan = plans.find((plan) => plan.planName === planName); + if (plan) { + return plan.seatMinimum; + } else { + return 0; + } + } + + static open(dialogService: DialogService, data: ManageClientOrganizationDialogParams) { + return dialogService.open(ManageClientOrganizationSubscriptionComponent, { data }); + } +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html new file mode 100644 index 00000000000..dc303d338f9 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html @@ -0,0 +1,90 @@ + + + + + {{ "addNewOrganization" | i18n }} + + + + + + {{ "loading" | i18n }} + + + +

{{ "noClientsInList" | i18n }}

+ + + + + {{ "client" | i18n }} + {{ "assigned" | i18n }} + {{ "used" | i18n }} + {{ "remaining" | i18n }} + {{ "billingPlan" | i18n }} + + + + + + + + + + + + + {{ client.seats }} + + + {{ client.userCount }} + + + {{ client.seats - client.userCount }} + + + {{ client.plan }} + + + + + + + + + + + + +
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts new file mode 100644 index 00000000000..79dd25e8912 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts @@ -0,0 +1,160 @@ +import { SelectionModel } from "@angular/cdk/collections"; +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { firstValueFrom } from "rxjs"; +import { first } from "rxjs/operators"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { SearchService } from "@bitwarden/common/abstractions/search.service"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { DialogService, TableDataSource } from "@bitwarden/components"; + +import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; + +import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-organization-subscription.component"; + +@Component({ + templateUrl: "manage-client-organizations.component.html", +}) + +// eslint-disable-next-line rxjs-angular/prefer-takeuntil +export class ManageClientOrganizationsComponent implements OnInit { + providerId: string; + loading = true; + manageOrganizations = false; + + set searchText(search: string) { + this.selection.clear(); + this.dataSource.filter = search; + } + + clients: ProviderOrganizationOrganizationDetailsResponse[]; + pagedClients: ProviderOrganizationOrganizationDetailsResponse[]; + + protected didScroll = false; + protected pageSize = 100; + protected actionPromise: Promise; + private pagedClientsCount = 0; + selection = new SelectionModel(true, []); + protected dataSource = new TableDataSource(); + + constructor( + private route: ActivatedRoute, + private providerService: ProviderService, + private apiService: ApiService, + private searchService: SearchService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private validationService: ValidationService, + private webProviderService: WebProviderService, + private dialogService: DialogService, + ) {} + + async ngOnInit() { + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe + this.route.parent.params.subscribe(async (params) => { + this.providerId = params.providerId; + + await this.load(); + + /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ + this.route.queryParams.pipe(first()).subscribe(async (qParams) => { + this.searchText = qParams.search; + }); + }); + } + + async load() { + const response = await this.apiService.getProviderClients(this.providerId); + this.clients = response.data != null && response.data.length > 0 ? response.data : []; + this.dataSource.data = this.clients; + this.manageOrganizations = + (await this.providerService.get(this.providerId)).type === ProviderUserType.ProviderAdmin; + + this.loading = false; + } + + isPaging() { + const searching = this.isSearching(); + if (searching && this.didScroll) { + // 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.resetPaging(); + } + return !searching && this.clients && this.clients.length > this.pageSize; + } + + isSearching() { + return this.searchService.isSearchable(this.searchText); + } + + async resetPaging() { + this.pagedClients = []; + this.loadMore(); + } + + loadMore() { + if (!this.clients || this.clients.length <= this.pageSize) { + return; + } + const pagedLength = this.pagedClients.length; + let pagedSize = this.pageSize; + if (pagedLength === 0 && this.pagedClientsCount > this.pageSize) { + pagedSize = this.pagedClientsCount; + } + if (this.clients.length > pagedLength) { + this.pagedClients = this.pagedClients.concat( + this.clients.slice(pagedLength, pagedLength + pagedSize), + ); + } + this.pagedClientsCount = this.pagedClients.length; + this.didScroll = this.pagedClients.length > this.pageSize; + } + + async manageSubscription(organization: ProviderOrganizationOrganizationDetailsResponse) { + if (organization == null) { + return; + } + + const dialogRef = ManageClientOrganizationSubscriptionComponent.open(this.dialogService, { + organization: organization, + }); + + await firstValueFrom(dialogRef.closed); + await this.load(); + } + + async remove(organization: ProviderOrganizationOrganizationDetailsResponse) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: organization.organizationName, + content: { key: "detachOrganizationConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + this.actionPromise = this.webProviderService.detachOrganization( + this.providerId, + organization.id, + ); + try { + await this.actionPromise; + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("detachedOrganization", organization.organizationName), + ); + await this.load(); + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = null; + } +} diff --git a/libs/angular/src/auth/components/base-login-decryption-options.component.ts b/libs/angular/src/auth/components/base-login-decryption-options.component.ts index 79202054c54..8345bb99396 100644 --- a/libs/angular/src/auth/components/base-login-decryption-options.component.ts +++ b/libs/angular/src/auth/components/base-login-decryption-options.component.ts @@ -15,15 +15,16 @@ import { } from "rxjs"; import { + LoginEmailServiceAbstraction, UserDecryptionOptions, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; @@ -34,6 +35,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { UserId } from "@bitwarden/common/types/guid"; enum State { NewUser, @@ -65,6 +67,8 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { protected data?: Data; protected loading = true; + activeAccountId: UserId; + // Remember device means for the user to trust the device rememberDeviceForm = this.formBuilder.group({ rememberDevice: [true], @@ -82,7 +86,7 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { protected activatedRoute: ActivatedRoute, protected messagingService: MessagingService, protected tokenService: TokenService, - protected loginService: LoginService, + protected loginEmailService: LoginEmailServiceAbstraction, protected organizationApiService: OrganizationApiServiceAbstraction, protected cryptoService: CryptoService, protected organizationUserService: OrganizationUserService, @@ -94,10 +98,12 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, protected passwordResetEnrollmentService: PasswordResetEnrollmentServiceAbstraction, protected ssoLoginService: SsoLoginServiceAbstraction, + protected accountService: AccountService, ) {} async ngOnInit() { this.loading = true; + this.activeAccountId = (await firstValueFrom(this.accountService.activeAccount$))?.id; this.setupRememberDeviceValueChanges(); @@ -150,7 +156,9 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { } private async setRememberDeviceDefaultValue() { - const rememberDeviceFromState = await this.deviceTrustCryptoService.getShouldTrustDevice(); + const rememberDeviceFromState = await this.deviceTrustCryptoService.getShouldTrustDevice( + this.activeAccountId, + ); const rememberDevice = rememberDeviceFromState ?? true; @@ -161,7 +169,9 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { this.rememberDevice.valueChanges .pipe( switchMap((value) => - defer(() => this.deviceTrustCryptoService.setShouldTrustDevice(value)), + defer(() => + this.deviceTrustCryptoService.setShouldTrustDevice(this.activeAccountId, value), + ), ), takeUntil(this.destroy$), ) @@ -244,23 +254,17 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { return; } - this.loginService.setEmail(this.data.userEmail); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/login-with-device"]); + this.loginEmailService.setEmail(this.data.userEmail); + await this.router.navigate(["/login-with-device"]); } async requestAdminApproval() { - this.loginService.setEmail(this.data.userEmail); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/admin-approval-requested"]); + this.loginEmailService.setEmail(this.data.userEmail); + await this.router.navigate(["/admin-approval-requested"]); } async approveWithMasterPassword() { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/lock"], { queryParams: { from: "login-initiated" } }); + await this.router.navigate(["/lock"], { queryParams: { from: "login-initiated" } }); } async createUser() { @@ -284,7 +288,7 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { await this.passwordResetEnrollmentService.enroll(this.data.organizationId); if (this.rememberDeviceForm.value.rememberDevice) { - await this.deviceTrustCryptoService.trustDevice(); + await this.deviceTrustCryptoService.trustDevice(this.activeAccountId); } } catch (error) { this.validationService.showError(error); diff --git a/libs/angular/src/auth/components/hint.component.ts b/libs/angular/src/auth/components/hint.component.ts index 54edc5b8faf..484604b6a5a 100644 --- a/libs/angular/src/auth/components/hint.component.ts +++ b/libs/angular/src/auth/components/hint.component.ts @@ -1,8 +1,8 @@ import { Directive, OnInit } from "@angular/core"; import { Router } from "@angular/router"; +import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { PasswordHintRequest } from "@bitwarden/common/auth/models/request/password-hint.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -22,11 +22,11 @@ export class HintComponent implements OnInit { protected apiService: ApiService, protected platformUtilsService: PlatformUtilsService, private logService: LogService, - private loginService: LoginService, + private loginEmailService: LoginEmailServiceAbstraction, ) {} ngOnInit(): void { - this.email = this.loginService.getEmail() ?? ""; + this.email = this.loginEmailService.getEmail() ?? ""; } async submit() { diff --git a/libs/angular/src/auth/components/lock.component.ts b/libs/angular/src/auth/components/lock.component.ts index c21ba1a75a1..aa3b801ded5 100644 --- a/libs/angular/src/auth/components/lock.component.ts +++ b/libs/angular/src/auth/components/lock.component.ts @@ -10,6 +10,7 @@ import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeou import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -75,6 +76,7 @@ export class LockComponent implements OnInit, OnDestroy { protected userVerificationService: UserVerificationService, protected pinCryptoService: PinCryptoServiceAbstraction, protected biometricStateService: BiometricStateService, + protected accountService: AccountService, ) {} async ngOnInit() { @@ -269,7 +271,8 @@ export class LockComponent implements OnInit, OnDestroy { // Now that we have a decrypted user key in memory, we can check if we // need to establish trust on the current device - await this.deviceTrustCryptoService.trustDeviceIfRequired(); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + await this.deviceTrustCryptoService.trustDeviceIfRequired(activeAccount.id); await this.doContinue(evaluatePasswordAfterUnlock); } diff --git a/libs/angular/src/auth/components/login-via-auth-request.component.ts b/libs/angular/src/auth/components/login-via-auth-request.component.ts index 45d7f563f7a..6ba94d30011 100644 --- a/libs/angular/src/auth/components/login-via-auth-request.component.ts +++ b/libs/angular/src/auth/components/login-via-auth-request.component.ts @@ -1,17 +1,18 @@ import { Directive, OnDestroy, OnInit } from "@angular/core"; import { IsActiveMatchOptions, Router } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { Subject, firstValueFrom, takeUntil } from "rxjs"; import { AuthRequestLoginCredentials, AuthRequestServiceAbstraction, LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable"; @@ -68,7 +69,6 @@ export class LoginViaAuthRequestComponent private authRequestKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array }; - // TODO: in future, go to child components and remove child constructors and let deps fall through to the super class constructor( protected router: Router, private cryptoService: CryptoService, @@ -84,10 +84,11 @@ export class LoginViaAuthRequestComponent private anonymousHubService: AnonymousHubService, private validationService: ValidationService, private stateService: StateService, - private loginService: LoginService, + private loginEmailService: LoginEmailServiceAbstraction, private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, private authRequestService: AuthRequestServiceAbstraction, private loginStrategyService: LoginStrategyServiceAbstraction, + private accountService: AccountService, ) { super(environmentService, i18nService, platformUtilsService); @@ -95,17 +96,19 @@ export class LoginViaAuthRequestComponent // Why would the existence of the email depend on the navigation? const navigation = this.router.getCurrentNavigation(); if (navigation) { - this.email = this.loginService.getEmail(); + this.email = this.loginEmailService.getEmail(); } - //gets signalR push notification - this.loginStrategyService.authRequestPushNotification$ + // Gets signalR push notification + // Only fires on approval to prevent enumeration + this.authRequestService.authRequestPushNotification$ .pipe(takeUntil(this.destroy$)) .subscribe((id) => { - // Only fires on approval currently - // 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.verifyAndHandleApprovedAuthReq(id); + this.verifyAndHandleApprovedAuthReq(id).catch((e: Error) => { + this.platformUtilsService.showToast("error", this.i18nService.t("error"), e.message); + this.logService.error("Failed to use approved auth request: " + e.message); + }); }); } @@ -150,7 +153,7 @@ export class LoginViaAuthRequestComponent } else { // Standard auth request // TODO: evaluate if we can remove the setting of this.email in the constructor - this.email = this.loginService.getEmail(); + this.email = this.loginEmailService.getEmail(); if (!this.email) { this.platformUtilsService.showToast("error", null, this.i18nService.t("userEmailMissing")); @@ -164,10 +167,10 @@ export class LoginViaAuthRequestComponent } } - ngOnDestroy(): void { + async ngOnDestroy() { + await this.anonymousHubService.stopHubConnection(); this.destroy$.next(); this.destroy$.complete(); - this.anonymousHubService.stopHubConnection(); } private async handleExistingAdminAuthRequest(adminAuthReqStorable: AdminAuthRequestStorable) { @@ -213,7 +216,7 @@ export class LoginViaAuthRequestComponent // Request still pending response from admin // So, create hub connection so that any approvals will be received via push notification - this.anonymousHubService.createHubConnection(adminAuthReqStorable.id); + await this.anonymousHubService.createHubConnection(adminAuthReqStorable.id); } private async handleExistingAdminAuthReqDeletedOrDenied() { @@ -273,7 +276,7 @@ export class LoginViaAuthRequestComponent } if (reqResponse.id) { - this.anonymousHubService.createHubConnection(reqResponse.id); + await this.anonymousHubService.createHubConnection(reqResponse.id); } } catch (e) { this.logService.error(e); @@ -387,7 +390,8 @@ export class LoginViaAuthRequestComponent // Now that we have a decrypted user key in memory, we can check if we // need to establish trust on the current device - await this.deviceTrustCryptoService.trustDeviceIfRequired(); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + await this.deviceTrustCryptoService.trustDeviceIfRequired(activeAccount.id); // TODO: don't forget to use auto enrollment service everywhere we trust device @@ -471,17 +475,10 @@ export class LoginViaAuthRequestComponent } } - async setRememberEmailValues() { - const rememberEmail = this.loginService.getRememberEmail(); - const rememberedEmail = this.loginService.getEmail(); - await this.stateService.setRememberedEmail(rememberEmail ? rememberedEmail : null); - this.loginService.clearValues(); - } - private async handleSuccessfulLoginNavigation() { if (this.state === State.StandardAuthRequest) { // Only need to set remembered email on standard login with auth req flow - await this.setRememberEmailValues(); + await this.loginEmailService.saveEmailSettings(); } if (this.onSuccessfulLogin != null) { diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index 217d3311988..bcdf747406e 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -4,9 +4,12 @@ import { ActivatedRoute, Router } from "@angular/router"; import { Subject, firstValueFrom } from "rxjs"; import { take, takeUntil } from "rxjs/operators"; -import { LoginStrategyServiceAbstraction, PasswordLoginCredentials } from "@bitwarden/auth/common"; +import { + LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, + PasswordLoginCredentials, +} from "@bitwarden/auth/common"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; @@ -77,7 +80,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, protected formBuilder: FormBuilder, protected formValidationErrorService: FormValidationErrorsService, protected route: ActivatedRoute, - protected loginService: LoginService, + protected loginEmailService: LoginEmailServiceAbstraction, protected ssoLoginService: SsoLoginServiceAbstraction, protected webAuthnLoginService: WebAuthnLoginServiceAbstraction, ) { @@ -93,25 +96,23 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, const queryParamsEmail = params.email; if (queryParamsEmail != null && queryParamsEmail.indexOf("@") > -1) { - this.formGroup.get("email").setValue(queryParamsEmail); - this.loginService.setEmail(queryParamsEmail); + this.formGroup.controls.email.setValue(queryParamsEmail); this.paramEmailSet = true; } }); - let email = this.loginService.getEmail(); - - if (email == null || email === "") { - email = await this.stateService.getRememberedEmail(); - } if (!this.paramEmailSet) { - this.formGroup.get("email")?.setValue(email ?? ""); + const storedEmail = await firstValueFrom(this.loginEmailService.storedEmail$); + this.formGroup.controls.email.setValue(storedEmail ?? ""); } - let rememberEmail = this.loginService.getRememberEmail(); + + let rememberEmail = this.loginEmailService.getRememberEmail(); + if (rememberEmail == null) { - rememberEmail = (await this.stateService.getRememberedEmail()) != null; + rememberEmail = (await firstValueFrom(this.loginEmailService.storedEmail$)) != null; } - this.formGroup.get("rememberEmail")?.setValue(rememberEmail); + + this.formGroup.controls.rememberEmail.setValue(rememberEmail); } ngOnDestroy() { @@ -148,8 +149,10 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, this.formPromise = this.loginStrategyService.logIn(credentials); const response = await this.formPromise; - this.setFormValues(); - await this.loginService.saveEmailSettings(); + + this.setLoginEmailValues(); + await this.loginEmailService.saveEmailSettings(); + if (this.handleCaptchaRequired(response)) { return; } else if (this.handleMigrateEncryptionKey(response)) { @@ -214,7 +217,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, return; } - this.setFormValues(); + this.setLoginEmailValues(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.router.navigate(["/login-with-device"]); @@ -292,14 +295,14 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, } } - setFormValues() { - this.loginService.setEmail(this.formGroup.value.email); - this.loginService.setRememberEmail(this.formGroup.value.rememberEmail); + setLoginEmailValues() { + this.loginEmailService.setEmail(this.formGroup.value.email); + this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail); } async saveEmailSettings() { - this.setFormValues(); - await this.loginService.saveEmailSettings(); + this.setLoginEmailValues(); + await this.loginEmailService.saveEmailSettings(); // Save off email for SSO await this.ssoLoginService.setSsoEmail(this.formGroup.value.email); diff --git a/libs/angular/src/auth/components/sso.component.spec.ts b/libs/angular/src/auth/components/sso.component.spec.ts index 82650cb7f18..c5c062d9a7c 100644 --- a/libs/angular/src/auth/components/sso.component.spec.ts +++ b/libs/angular/src/auth/components/sso.component.spec.ts @@ -16,7 +16,7 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/ import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -66,7 +66,7 @@ describe("SsoComponent", () => { let mockPasswordGenerationService: MockProxy; let mockLogService: MockProxy; let mockUserDecryptionOptionsService: MockProxy; - let mockConfigService: MockProxy; + let mockConfigService: MockProxy; // Mock authService.logIn params let code: string; @@ -107,16 +107,16 @@ describe("SsoComponent", () => { queryParams: mockQueryParams, } as any as ActivatedRoute; - mockSsoLoginService = mock(); - mockStateService = mock(); - mockPlatformUtilsService = mock(); - mockApiService = mock(); - mockCryptoFunctionService = mock(); - mockEnvironmentService = mock(); - mockPasswordGenerationService = mock(); - mockLogService = mock(); - mockUserDecryptionOptionsService = mock(); - mockConfigService = mock(); + mockSsoLoginService = mock(); + mockStateService = mock(); + mockPlatformUtilsService = mock(); + mockApiService = mock(); + mockCryptoFunctionService = mock(); + mockEnvironmentService = mock(); + mockPasswordGenerationService = mock(); + mockLogService = mock(); + mockUserDecryptionOptionsService = mock(); + mockConfigService = mock(); // Mock loginStrategyService.logIn params code = "code"; @@ -198,7 +198,7 @@ describe("SsoComponent", () => { useValue: mockUserDecryptionOptionsService, }, { provide: LogService, useValue: mockLogService }, - { provide: ConfigServiceAbstraction, useValue: mockConfigService }, + { provide: ConfigService, useValue: mockConfigService }, ], }); diff --git a/libs/angular/src/auth/components/sso.component.ts b/libs/angular/src/auth/components/sso.component.ts index f7d5504e082..68d6e72e8d6 100644 --- a/libs/angular/src/auth/components/sso.component.ts +++ b/libs/angular/src/auth/components/sso.component.ts @@ -15,7 +15,7 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -65,7 +65,7 @@ export class SsoComponent { protected passwordGenerationService: PasswordGenerationServiceAbstraction, protected logService: LogService, protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - protected configService: ConfigServiceAbstraction, + protected configService: ConfigService, ) {} async ngOnInit() { diff --git a/libs/angular/src/auth/components/two-factor.component.spec.ts b/libs/angular/src/auth/components/two-factor.component.spec.ts index c27ba7082f0..bff39188ea9 100644 --- a/libs/angular/src/auth/components/two-factor.component.spec.ts +++ b/libs/angular/src/auth/components/two-factor.component.spec.ts @@ -7,21 +7,21 @@ import { BehaviorSubject } from "rxjs"; // eslint-disable-next-line no-restricted-imports import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { - FakeKeyConnectorUserDecryptionOption as KeyConnectorUserDecryptionOption, LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, + FakeKeyConnectorUserDecryptionOption as KeyConnectorUserDecryptionOption, FakeTrustedDeviceUserDecryptionOption as TrustedDeviceUserDecryptionOption, FakeUserDecryptionOptions as UserDecryptionOptions, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -59,10 +59,10 @@ describe("TwoFactorComponent", () => { let mockLogService: MockProxy; let mockTwoFactorService: MockProxy; let mockAppIdService: MockProxy; - let mockLoginService: MockProxy; + let mockLoginEmailService: MockProxy; let mockUserDecryptionOptionsService: MockProxy; let mockSsoLoginService: MockProxy; - let mockConfigService: MockProxy; + let mockConfigService: MockProxy; let mockUserDecryptionOpts: { noMasterPassword: UserDecryptionOptions; @@ -89,10 +89,10 @@ describe("TwoFactorComponent", () => { mockLogService = mock(); mockTwoFactorService = mock(); mockAppIdService = mock(); - mockLoginService = mock(); + mockLoginEmailService = mock(); mockUserDecryptionOptionsService = mock(); mockSsoLoginService = mock(); - mockConfigService = mock(); + mockConfigService = mock(); mockUserDecryptionOpts = { noMasterPassword: new UserDecryptionOptions({ @@ -163,13 +163,13 @@ describe("TwoFactorComponent", () => { { provide: LogService, useValue: mockLogService }, { provide: TwoFactorService, useValue: mockTwoFactorService }, { provide: AppIdService, useValue: mockAppIdService }, - { provide: LoginService, useValue: mockLoginService }, + { provide: LoginEmailServiceAbstraction, useValue: mockLoginEmailService }, { provide: UserDecryptionOptionsServiceAbstraction, useValue: mockUserDecryptionOptionsService, }, { provide: SsoLoginServiceAbstraction, useValue: mockSsoLoginService }, - { provide: ConfigServiceAbstraction, useValue: mockConfigService }, + { provide: ConfigService, useValue: mockConfigService }, ], }); @@ -280,11 +280,11 @@ describe("TwoFactorComponent", () => { expect(component.onSuccessfulLogin).toHaveBeenCalled(); }); - it("calls loginService.clearValues() when login is successful", async () => { + it("calls loginEmailService.clearValues() when login is successful", async () => { // Arrange mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); - // spy on loginService.clearValues - const clearValuesSpy = jest.spyOn(mockLoginService, "clearValues"); + // spy on loginEmailService.clearValues + const clearValuesSpy = jest.spyOn(mockLoginEmailService, "clearValues"); // Act await component.doSubmit(); diff --git a/libs/angular/src/auth/components/two-factor.component.ts b/libs/angular/src/auth/components/two-factor.component.ts index 78d1c020b8c..c306e6cc804 100644 --- a/libs/angular/src/auth/components/two-factor.component.ts +++ b/libs/angular/src/auth/components/two-factor.component.ts @@ -8,12 +8,12 @@ import { first } from "rxjs/operators"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { LoginStrategyServiceAbstraction, + LoginEmailServiceAbstraction, TrustedDeviceUserDecryptionOption, UserDecryptionOptions, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; @@ -25,7 +25,7 @@ import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service"; import { WebAuthnIFrame } from "@bitwarden/common/auth/webauthn-iframe"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -88,10 +88,10 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI protected logService: LogService, protected twoFactorService: TwoFactorService, protected appIdService: AppIdService, - protected loginService: LoginService, + protected loginEmailService: LoginEmailServiceAbstraction, protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, protected ssoLoginService: SsoLoginServiceAbstraction, - protected configService: ConfigServiceAbstraction, + protected configService: ConfigService, ) { super(environmentService, i18nService, platformUtilsService); this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win); @@ -288,7 +288,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI // - TDE login decryption options component // - Browser SSO on extension open await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier); - this.loginService.clearValues(); + this.loginEmailService.clearValues(); // note: this flow affects both TDE & standard users if (this.isForcePasswordResetRequired(authResult)) { diff --git a/libs/angular/src/components/share.component.ts b/libs/angular/src/components/share.component.ts index 53f064d6f4a..6687e784f01 100644 --- a/libs/angular/src/components/share.component.ts +++ b/libs/angular/src/components/share.component.ts @@ -62,6 +62,7 @@ export class ShareComponent implements OnInit, OnDestroy { this.organizations$.pipe(takeUntil(this._destroy)).subscribe((orgs) => { if (this.organizationId == null && orgs.length > 0) { this.organizationId = orgs[0].id; + this.filterCollections(); } }); @@ -69,8 +70,6 @@ export class ShareComponent implements OnInit, OnDestroy { this.cipher = await cipherDomain.decrypt( await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain), ); - - this.filterCollections(); } filterCollections() { diff --git a/libs/angular/src/directives/if-feature.directive.spec.ts b/libs/angular/src/directives/if-feature.directive.spec.ts index 01364b2ada2..944410be7d5 100644 --- a/libs/angular/src/directives/if-feature.directive.spec.ts +++ b/libs/angular/src/directives/if-feature.directive.spec.ts @@ -4,7 +4,7 @@ import { By } from "@angular/platform-browser"; import { mock, MockProxy } from "jest-mock-extended"; import { FeatureFlag, FeatureFlagValue } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { IfFeatureDirective } from "./if-feature.directive"; @@ -39,7 +39,7 @@ class TestComponent { describe("IfFeatureDirective", () => { let fixture: ComponentFixture; let content: HTMLElement; - let mockConfigService: MockProxy; + let mockConfigService: MockProxy; const mockConfigFlagValue = (flag: FeatureFlag, flagValue: FeatureFlagValue) => { mockConfigService.getFeatureFlag.mockImplementation((f, defaultValue) => @@ -51,14 +51,14 @@ describe("IfFeatureDirective", () => { fixture.debugElement.query(By.css(`[data-testid="${testId}"]`))?.nativeElement; beforeEach(async () => { - mockConfigService = mock(); + mockConfigService = mock(); await TestBed.configureTestingModule({ declarations: [IfFeatureDirective, TestComponent], providers: [ { provide: LogService, useValue: mock() }, { - provide: ConfigServiceAbstraction, + provide: ConfigService, useValue: mockConfigService, }, ], diff --git a/libs/angular/src/directives/if-feature.directive.ts b/libs/angular/src/directives/if-feature.directive.ts index ff081256787..069f306a895 100644 --- a/libs/angular/src/directives/if-feature.directive.ts +++ b/libs/angular/src/directives/if-feature.directive.ts @@ -1,7 +1,7 @@ import { Directive, Input, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; import { FeatureFlag, FeatureFlagValue } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; /** @@ -30,7 +30,7 @@ export class IfFeatureDirective implements OnInit { constructor( private templateRef: TemplateRef, private viewContainer: ViewContainerRef, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private logService: LogService, ) {} diff --git a/libs/angular/src/platform/abstractions/form-validation-errors.service.ts b/libs/angular/src/platform/abstractions/form-validation-errors.service.ts index 08a12443a0c..266afff5f34 100644 --- a/libs/angular/src/platform/abstractions/form-validation-errors.service.ts +++ b/libs/angular/src/platform/abstractions/form-validation-errors.service.ts @@ -9,5 +9,5 @@ export interface FormGroupControls { } export abstract class FormValidationErrorsService { - getFormValidationErrors: (controls: FormGroupControls) => AllValidationErrors[]; + abstract getFormValidationErrors(controls: FormGroupControls): AllValidationErrors[]; } diff --git a/libs/angular/src/platform/guard/feature-flag.guard.spec.ts b/libs/angular/src/platform/guard/feature-flag.guard.spec.ts index 95dd56cd50b..88637dff978 100644 --- a/libs/angular/src/platform/guard/feature-flag.guard.spec.ts +++ b/libs/angular/src/platform/guard/feature-flag.guard.spec.ts @@ -5,7 +5,7 @@ import { RouterTestingModule } from "@angular/router/testing"; import { mock, MockProxy } from "jest-mock-extended"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -21,11 +21,11 @@ describe("canAccessFeature", () => { const featureRoute = "enabled-feature"; const redirectRoute = "redirect"; - let mockConfigService: MockProxy; + let mockConfigService: MockProxy; let mockPlatformUtilsService: MockProxy; const setup = (featureGuard: CanActivateFn, flagValue: any) => { - mockConfigService = mock(); + mockConfigService = mock(); mockPlatformUtilsService = mock(); // Mock the correct getter based on the type of flagValue; also mock default values if one is not provided @@ -56,7 +56,7 @@ describe("canAccessFeature", () => { ]), ], providers: [ - { provide: ConfigServiceAbstraction, useValue: mockConfigService }, + { provide: ConfigService, useValue: mockConfigService }, { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, { provide: LogService, useValue: mock() }, { diff --git a/libs/angular/src/platform/guard/feature-flag.guard.ts b/libs/angular/src/platform/guard/feature-flag.guard.ts index 8842f04152b..bfcabc2b53c 100644 --- a/libs/angular/src/platform/guard/feature-flag.guard.ts +++ b/libs/angular/src/platform/guard/feature-flag.guard.ts @@ -2,7 +2,7 @@ import { inject } from "@angular/core"; import { CanActivateFn, Router } from "@angular/router"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -23,7 +23,7 @@ export const canAccessFeature = ( redirectUrlOnDisabled?: string, ): CanActivateFn => { return async () => { - const configService = inject(ConfigServiceAbstraction); + const configService = inject(ConfigService); const platformUtilsService = inject(PlatformUtilsService); const router = inject(Router); const i18nService = inject(I18nService); diff --git a/libs/angular/src/platform/services/logging-error-handler.ts b/libs/angular/src/platform/services/logging-error-handler.ts new file mode 100644 index 00000000000..522412dd288 --- /dev/null +++ b/libs/angular/src/platform/services/logging-error-handler.ts @@ -0,0 +1,22 @@ +import { ErrorHandler, Injectable, Injector, inject } from "@angular/core"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +@Injectable() +export class LoggingErrorHandler extends ErrorHandler { + /** + * When injecting services into an `ErrorHandler`, we must use the `Injector` manually to avoid circular dependency errors. + * + * https://stackoverflow.com/a/57115053 + */ + private injector = inject(Injector); + + override handleError(error: any): void { + try { + const logService = this.injector.get(LogService, null); + logService.error(error); + } catch { + super.handleError(error); + } + } +} diff --git a/libs/angular/src/platform/services/theming/theming.service.abstraction.ts b/libs/angular/src/platform/services/theming/theming.service.abstraction.ts index 9a012a7f75b..4306d312c5e 100644 --- a/libs/angular/src/platform/services/theming/theming.service.abstraction.ts +++ b/libs/angular/src/platform/services/theming/theming.service.abstraction.ts @@ -11,12 +11,12 @@ export abstract class AbstractThemingService { * The effective theme based on the user configured choice and the current system theme if * the configured choice is {@link ThemeType.System}. */ - theme$: Observable; + abstract theme$: Observable; /** * Listens for effective theme changes and applies changes to the provided document. * @param document The document that should have theme classes applied to it. * * @returns A subscription that can be unsubscribed from to cancel the application of theme classes. */ - applyThemeChangesTo: (document: Document) => Subscription; + abstract applyThemeChangesTo(document: Document): Subscription; } diff --git a/libs/angular/src/platform/utils/safe-provider.ts b/libs/angular/src/platform/utils/safe-provider.ts index 65ce49cda94..7c19a280d62 100644 --- a/libs/angular/src/platform/utils/safe-provider.ts +++ b/libs/angular/src/platform/utils/safe-provider.ts @@ -4,7 +4,7 @@ import { Constructor, Opaque } from "type-fest"; import { SafeInjectionToken } from "../../services/injection-tokens"; /** - * The return type of our dependency helper functions. + * The return type of the {@link safeProvider} helper function. * Used to distinguish a type safe provider definition from a non-type safe provider definition. */ export type SafeProvider = Opaque; @@ -18,12 +18,22 @@ type MapParametersToDeps = { type SafeInjectionTokenType = T extends SafeInjectionToken ? J : never; +/** + * Gets the instance type from a constructor, abstract constructor, or SafeInjectionToken + */ +type ProviderInstanceType = + T extends SafeInjectionToken + ? InstanceType> + : T extends Constructor | AbstractConstructor + ? InstanceType + : never; + /** * Represents a dependency provided with the useClass option. */ type SafeClassProvider< - A extends AbstractConstructor, - I extends Constructor>, + A extends AbstractConstructor | SafeInjectionToken, + I extends Constructor>, D extends MapParametersToDeps>, > = { provide: A; @@ -40,42 +50,41 @@ type SafeValueProvider, V extends SafeInjectio }; /** - * Represents a dependency provided with the useFactory option where a SafeInjectionToken is used as the token. + * Represents a dependency provided with the useFactory option. */ -type SafeFactoryProviderWithToken< - A extends SafeInjectionToken, - I extends (...args: any) => InstanceType>, - D extends MapParametersToDeps>, -> = { - provide: A; - useFactory: I; - deps: D; -}; - -/** - * Represents a dependency provided with the useFactory option where an abstract class is used as the token. - */ -type SafeFactoryProviderWithClass< - A extends AbstractConstructor, - I extends (...args: any) => InstanceType, +type SafeFactoryProvider< + A extends AbstractConstructor | SafeInjectionToken, + I extends (...args: any) => ProviderInstanceType, D extends MapParametersToDeps>, > = { provide: A; useFactory: I; deps: D; + multi?: boolean; }; /** * Represents a dependency provided with the useExisting option. */ type SafeExistingProvider< - A extends Constructor | AbstractConstructor, - I extends Constructor> | AbstractConstructor>, + A extends Constructor | AbstractConstructor | SafeInjectionToken, + I extends Constructor> | AbstractConstructor>, > = { provide: A; useExisting: I; }; +/** + * Represents a dependency where there is no abstract token, the token is the implementation + */ +type SafeConcreteProvider< + I extends Constructor, + D extends MapParametersToDeps>, +> = { + provide: I; + deps: D; +}; + /** * A factory function that creates a provider for the ngModule providers array. * This guarantees type safety for your provider definition. It does nothing at runtime. @@ -84,31 +93,30 @@ type SafeExistingProvider< */ export const safeProvider = < // types for useClass - AClass extends AbstractConstructor, - IClass extends Constructor>, + AClass extends AbstractConstructor | SafeInjectionToken, + IClass extends Constructor>, DClass extends MapParametersToDeps>, // types for useValue AValue extends SafeInjectionToken, VValue extends SafeInjectionTokenType, - // types for useFactoryWithToken - AFactoryToken extends SafeInjectionToken, - IFactoryToken extends (...args: any) => InstanceType>, - DFactoryToken extends MapParametersToDeps>, - // types for useFactoryWithClass - AFactoryClass extends AbstractConstructor, - IFactoryClass extends (...args: any) => InstanceType, - DFactoryClass extends MapParametersToDeps>, + // types for useFactory + AFactory extends AbstractConstructor | SafeInjectionToken, + IFactory extends (...args: any) => ProviderInstanceType, + DFactory extends MapParametersToDeps>, // types for useExisting - AExisting extends Constructor | AbstractConstructor, + AExisting extends Constructor | AbstractConstructor | SafeInjectionToken, IExisting extends - | Constructor> - | AbstractConstructor>, + | Constructor> + | AbstractConstructor>, + // types for no token + IConcrete extends Constructor, + DConcrete extends MapParametersToDeps>, >( provider: | SafeClassProvider | SafeValueProvider - | SafeFactoryProviderWithToken - | SafeFactoryProviderWithClass + | SafeFactoryProvider | SafeExistingProvider + | SafeConcreteProvider | Constructor, ): SafeProvider => provider as SafeProvider; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index beda7cbf4f5..73f2bb4a32f 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1,5 +1,4 @@ -import { LOCALE_ID, NgModule } from "@angular/core"; -import { UnwrapOpaque } from "type-fest"; +import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core"; import { AuthRequestServiceAbstraction, @@ -8,6 +7,8 @@ import { PinCryptoService, LoginStrategyServiceAbstraction, LoginStrategyService, + LoginEmailServiceAbstraction, + LoginEmailService, InternalUserDecryptionOptionsServiceAbstraction, UserDecryptionOptionsService, UserDecryptionOptionsServiceAbstraction, @@ -59,7 +60,6 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service"; -import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service"; import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; @@ -78,7 +78,6 @@ import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; -import { LoginService } from "@bitwarden/common/auth/services/login.service"; import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation"; import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; @@ -111,7 +110,7 @@ import { PaymentMethodWarningsService } from "@bitwarden/common/billing/services import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -135,7 +134,7 @@ import { Account } from "@bitwarden/common/platform/models/domain/account"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; -import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; +import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { CryptoService } from "@bitwarden/common/platform/services/crypto.service"; import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; @@ -192,6 +191,8 @@ import { } from "@bitwarden/common/tools/password-strength"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendStateProvider as SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; +import { SendStateProvider as SendStateProviderAbstraction } from "@bitwarden/common/tools/send/services/send-state.provider.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { InternalSendService, @@ -239,6 +240,7 @@ import { UnauthGuard } from "../auth/guards/unauth.guard"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; import { BroadcasterService } from "../platform/services/broadcaster.service"; import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; +import { LoggingErrorHandler } from "../platform/services/logging-error-handler"; import { AngularThemingService } from "../platform/services/theming/angular-theming.service"; import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction"; import { safeProvider, SafeProvider } from "../platform/utils/safe-provider"; @@ -267,7 +269,7 @@ import { ModalService } from "./modal.service"; * Add your provider definition here using the safeProvider function as a wrapper. This will give you type safety. * If you need help please ask for it, do NOT change the type of this array. */ -const typesafeProviders: Array = [ +const safeProviders: SafeProvider[] = [ safeProvider(AuthGuard), safeProvider(UnauthGuard), safeProvider(ModalService), @@ -345,10 +347,12 @@ const typesafeProviders: Array = [ provide: AuthServiceAbstraction, useClass: AuthService, deps: [ + AccountServiceAbstraction, MessagingServiceAbstraction, CryptoServiceAbstraction, ApiServiceAbstraction, StateServiceAbstraction, + TokenServiceAbstraction, ], }), safeProvider({ @@ -399,7 +403,7 @@ const typesafeProviders: Array = [ autofillSettingsService: AutofillSettingsServiceAbstraction, encryptService: EncryptService, fileUploadService: CipherFileUploadServiceAbstraction, - configService: ConfigServiceAbstraction, + configService: ConfigService, ) => new CipherService( cryptoService, @@ -423,7 +427,7 @@ const typesafeProviders: Array = [ AutofillSettingsServiceAbstraction, EncryptService, CipherFileUploadServiceAbstraction, - ConfigServiceAbstraction, + ConfigService, ], }), safeProvider({ @@ -502,7 +506,10 @@ const typesafeProviders: Array = [ SingleUserStateProvider, GlobalStateProvider, SUPPORTS_SECURE_STORAGE, - AbstractStorageService, + SECURE_STORAGE, + KeyGenerationServiceAbstraction, + EncryptService, + LogService, ], }), safeProvider({ @@ -562,9 +569,15 @@ const typesafeProviders: Array = [ CryptoServiceAbstraction, I18nServiceAbstraction, KeyGenerationServiceAbstraction, - StateServiceAbstraction, + SendStateProviderAbstraction, + EncryptService, ], }), + safeProvider({ + provide: SendStateProviderAbstraction, + useClass: SendStateProvider, + deps: [StateProvider], + }), safeProvider({ provide: SendApiServiceAbstraction, useClass: SendApiService, @@ -762,7 +775,6 @@ const typesafeProviders: Array = [ provide: KeyConnectorServiceAbstraction, useClass: KeyConnectorService, deps: [ - StateServiceAbstraction, CryptoServiceAbstraction, ApiServiceAbstraction, TokenServiceAbstraction, @@ -770,6 +782,7 @@ const typesafeProviders: Array = [ OrganizationServiceAbstraction, KeyGenerationServiceAbstraction, LOGOUT_CALLBACK, + StateProvider, ], }), safeProvider({ @@ -847,30 +860,23 @@ const typesafeProviders: Array = [ deps: [], }), safeProvider({ - provide: ConfigService, - useClass: ConfigService, - deps: [ - StateServiceAbstraction, - ConfigApiServiceAbstraction, - AuthServiceAbstraction, - EnvironmentService, - LogService, - StateProvider, - ], + provide: DefaultConfigService, + useClass: DefaultConfigService, + deps: [ConfigApiServiceAbstraction, EnvironmentService, LogService, StateProvider], }), safeProvider({ - provide: ConfigServiceAbstraction, - useExisting: ConfigService, + provide: ConfigService, + useExisting: DefaultConfigService, }), safeProvider({ provide: ConfigApiServiceAbstraction, useClass: ConfigApiService, - deps: [ApiServiceAbstraction, AuthServiceAbstraction], + deps: [ApiServiceAbstraction, TokenServiceAbstraction], }), safeProvider({ provide: AnonymousHubServiceAbstraction, useClass: AnonymousHubService, - deps: [EnvironmentService, LoginStrategyServiceAbstraction, LogService], + deps: [EnvironmentService, AuthRequestServiceAbstraction], }), safeProvider({ provide: ValidationServiceAbstraction, @@ -878,9 +884,9 @@ const typesafeProviders: Array = [ deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction], }), safeProvider({ - provide: LoginServiceAbstraction, - useClass: LoginService, - deps: [StateServiceAbstraction], + provide: LoginEmailServiceAbstraction, + useClass: LoginEmailService, + deps: [StateProvider], }), safeProvider({ provide: OrgDomainInternalServiceAbstraction, @@ -914,11 +920,12 @@ const typesafeProviders: Array = [ CryptoFunctionServiceAbstraction, CryptoServiceAbstraction, EncryptService, - StateServiceAbstraction, AppIdServiceAbstraction, DevicesApiServiceAbstraction, I18nServiceAbstraction, PlatformUtilsServiceAbstraction, + StateProvider, + SECURE_STORAGE, UserDecryptionOptionsServiceAbstraction, ], }), @@ -1066,13 +1073,18 @@ const typesafeProviders: Array = [ safeProvider({ provide: BillingAccountProfileStateService, useClass: DefaultBillingAccountProfileStateService, - deps: [ActiveUserStateProvider], + deps: [StateProvider], }), safeProvider({ provide: OrganizationManagementPreferencesService, useClass: DefaultOrganizationManagementPreferencesService, deps: [StateProvider], }), + safeProvider({ + provide: ErrorHandler, + useClass: LoggingErrorHandler, + deps: [], + }), ]; function encryptServiceFactory( @@ -1088,6 +1100,6 @@ function encryptServiceFactory( @NgModule({ declarations: [], // Do not register your dependency here! Add it to the typesafeProviders array using the helper function - providers: typesafeProviders as UnwrapOpaque[], + providers: safeProviders, }) export class JslibServicesModule {} diff --git a/libs/angular/src/tools/send/send.component.ts b/libs/angular/src/tools/send/send.component.ts index b3b871a1776..90d9b39e8c6 100644 --- a/libs/angular/src/tools/send/send.component.ts +++ b/libs/angular/src/tools/send/send.component.ts @@ -1,5 +1,5 @@ import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core"; -import { Subject, firstValueFrom, takeUntil } from "rxjs"; +import { Subject, firstValueFrom, mergeMap, takeUntil } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -77,9 +77,15 @@ export class SendComponent implements OnInit, OnDestroy { async load(filter: (send: SendView) => boolean = null) { this.loading = true; - this.sendService.sendViews$.pipe(takeUntil(this.destroy$)).subscribe((sends) => { - this.sends = sends; - }); + this.sendService.sendViews$ + .pipe( + mergeMap(async (sends) => { + this.sends = sends; + await this.search(null); + }), + takeUntil(this.destroy$), + ) + .subscribe(); if (this.onSuccessfulLoad != null) { await this.onSuccessfulLoad(); } else { diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 680672514a8..4c177a77f2f 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -14,7 +14,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { EventType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -119,7 +119,7 @@ export class AddEditComponent implements OnInit, OnDestroy { protected dialogService: DialogService, protected win: Window, protected datePipe: DatePipe, - protected configService: ConfigServiceAbstraction, + protected configService: ConfigService, ) { this.typeOptions = [ { name: i18nService.t("typeLogin"), value: CipherType.Login }, @@ -402,6 +402,14 @@ export class AddEditComponent implements OnInit, OnDestroy { } } + removePasskey() { + if (this.cipher.type !== CipherType.Login || this.cipher.login.fido2Credentials == null) { + return; + } + + this.cipher.login.fido2Credentials = null; + } + onCardNumberChange(): void { this.cipher.card.brand = CardView.getCardBrandByPatterns(this.cipher.card.number); } @@ -650,11 +658,11 @@ export class AddEditComponent implements OnInit, OnDestroy { protected saveCipher(cipher: Cipher) { const isNotClone = this.editMode && !this.cloneMode; - let orgAdmin = this.organization?.isAdmin; + let orgAdmin = this.organization?.canEditAllCiphers(this.flexibleCollectionsV1Enabled); - if (this.flexibleCollectionsV1Enabled) { - // Flexible Collections V1 restricts admins, check the organization setting via canEditAllCiphers - orgAdmin = this.organization?.canEditAllCiphers(true); + // if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection + if (!cipher.collectionIds) { + orgAdmin = this.organization?.canEditAnyCollection; } return this.cipher.id == null diff --git a/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts b/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts index b91444d3e62..7af92fc8f8b 100644 --- a/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts +++ b/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts @@ -1,7 +1,12 @@ +import { Observable } from "rxjs"; + import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; +import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; import { UserKey, MasterKey } from "@bitwarden/common/types/key"; export abstract class AuthRequestServiceAbstraction { + /** Emits an auth request id when an auth request has been approved. */ + authRequestPushNotification$: Observable; /** * Approve or deny an auth request. * @param approve True to approve, false to deny. @@ -54,4 +59,11 @@ export abstract class AuthRequestServiceAbstraction { pubKeyEncryptedMasterKeyHash: string, privateKey: ArrayBuffer, ) => Promise<{ masterKey: MasterKey; masterKeyHash: string }>; + + /** + * Handles incoming auth request push notifications. + * @param notification push notification. + * @remark We should only be receiving approved push notifications to prevent enumeration. + */ + abstract sendAuthRequestPushNotification: (notification: AuthRequestPushNotification) => void; } diff --git a/libs/auth/src/common/abstractions/index.ts b/libs/auth/src/common/abstractions/index.ts index 1feee6695a9..71280b72f63 100644 --- a/libs/auth/src/common/abstractions/index.ts +++ b/libs/auth/src/common/abstractions/index.ts @@ -1,4 +1,5 @@ export * from "./pin-crypto.service.abstraction"; +export * from "./login-email.service"; export * from "./login-strategy.service"; export * from "./user-decryption-options.service.abstraction"; export * from "./auth-request.service.abstraction"; diff --git a/libs/auth/src/common/abstractions/login-email.service.ts b/libs/auth/src/common/abstractions/login-email.service.ts new file mode 100644 index 00000000000..89165af5431 --- /dev/null +++ b/libs/auth/src/common/abstractions/login-email.service.ts @@ -0,0 +1,38 @@ +import { Observable } from "rxjs"; + +export abstract class LoginEmailServiceAbstraction { + /** + * An observable that monitors the storedEmail + */ + storedEmail$: Observable; + /** + * Gets the current email being used in the login process. + * @returns A string of the email. + */ + getEmail: () => string; + /** + * Sets the current email being used in the login process. + * @param email The email to be set. + */ + setEmail: (email: string) => void; + /** + * Gets whether or not the email should be stored on disk. + * @returns A boolean stating whether or not the email should be stored on disk. + */ + getRememberEmail: () => boolean; + /** + * Sets whether or not the email should be stored on disk. + */ + setRememberEmail: (value: boolean) => void; + /** + * Sets the email and rememberEmail properties to null. + */ + clearValues: () => void; + /** + * - If rememberEmail is true, sets the storedEmail on disk to the current email. + * - If rememberEmail is false, sets the storedEmail on disk to null. + * - Then sets the email and rememberEmail properties to null. + * @returns A promise that resolves once the email settings are saved. + */ + saveEmailSettings: () => Promise; +} diff --git a/libs/auth/src/common/abstractions/login-strategy.service.ts b/libs/auth/src/common/abstractions/login-strategy.service.ts index e3ed63c7374..eae6dc2a275 100644 --- a/libs/auth/src/common/abstractions/login-strategy.service.ts +++ b/libs/auth/src/common/abstractions/login-strategy.service.ts @@ -4,7 +4,6 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication- import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; -import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; import { MasterKey } from "@bitwarden/common/types/key"; import { @@ -21,10 +20,6 @@ export abstract class LoginStrategyServiceAbstraction { * Emits null if the session has timed out. */ currentAuthType$: Observable; - /** - * Emits when an auth request has been approved. - */ - authRequestPushNotification$: Observable; /** * If the login strategy uses the email address of the user, this * will return it. Otherwise, it will return null. @@ -77,10 +72,6 @@ export abstract class LoginStrategyServiceAbstraction { * Creates a master key from the provided master password and email. */ makePreloginKey: (masterPassword: string, email: string) => Promise; - /** - * Sends a notification to {@link LoginStrategyServiceAbstraction.authRequestPushNotification} - */ - sendAuthRequestPushNotification: (notification: AuthRequestPushNotification) => Promise; /** * Sends a response to an auth request. */ diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts index c42f43e7643..31a0cebbfee 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts @@ -16,6 +16,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; import { AuthRequestLoginCredentials } from "../models/domain/login-credentials"; @@ -128,8 +129,10 @@ export class AuthRequestLoginStrategy extends LoginStrategy { await this.cryptoService.setUserKey(authRequestCredentials.decryptedUserKey); } else { await this.trySetUserKeyWithMasterKey(); + + const userId = (await this.stateService.getUserId()) as UserId; // Establish trust if required after setting user key - await this.deviceTrustCryptoService.trustDeviceIfRequired(); + await this.deviceTrustCryptoService.trustDeviceIfRequired(userId); } } diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index ed40797df51..0ac22047c5b 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -36,7 +36,7 @@ import { PasswordStrengthService, } from "@bitwarden/common/tools/password-strength"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { UserKey, MasterKey, DeviceKey } from "@bitwarden/common/types/key"; +import { UserKey, MasterKey } from "@bitwarden/common/types/key"; import { LoginStrategyServiceAbstraction } from "../abstractions"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; @@ -186,9 +186,9 @@ describe("LoginStrategy", () => { expect(tokenService.setTokens).toHaveBeenCalledWith( accessToken, - refreshToken, mockVaultTimeoutAction, mockVaultTimeout, + refreshToken, ); expect(stateService.addAccount).toHaveBeenCalledWith( @@ -215,29 +215,6 @@ describe("LoginStrategy", () => { expect(messagingService.send).toHaveBeenCalledWith("loggedIn"); }); - it("persists a device key for trusted device encryption when it exists on login", async () => { - // Arrange - const idTokenResponse = identityTokenResponseFactory(); - apiService.postIdentityToken.mockResolvedValue(idTokenResponse); - - const deviceKey = new SymmetricCryptoKey( - new Uint8Array(userKeyBytesLength).buffer as CsprngArray, - ) as DeviceKey; - - stateService.getDeviceKey.mockResolvedValue(deviceKey); - - const accountKeys = new AccountKeys(); - accountKeys.deviceKey = deviceKey; - - // Act - await passwordLoginStrategy.logIn(credentials); - - // Assert - expect(stateService.addAccount).toHaveBeenCalledWith( - expect.objectContaining({ keys: accountKeys }), - ); - }); - it("builds AuthResult", async () => { const tokenResponse = identityTokenResponseFactory(); tokenResponse.forcePasswordReset = true; diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index eef5626493b..4fe99b276cf 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -26,7 +26,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { - AccountKeys, Account, AccountProfile, AccountTokens, @@ -160,18 +159,8 @@ export abstract class LoginStrategy { protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise { const accountInformation = await this.tokenService.decodeAccessToken(tokenResponse.accessToken); - // Must persist existing device key if it exists for trusted device decryption to work - // However, we must provide a user id so that the device key can be retrieved - // as the state service won't have an active account at this point in time - // even though the data exists in local storage. const userId = accountInformation.sub; - const deviceKey = await this.stateService.getDeviceKey({ userId }); - const accountKeys = new AccountKeys(); - if (deviceKey) { - accountKeys.deviceKey = deviceKey; - } - // If you don't persist existing admin auth requests on login, they will get deleted. const adminAuthRequest = await this.stateService.getAdminAuthRequest({ userId }); @@ -182,9 +171,9 @@ export abstract class LoginStrategy { // User id will be derived from the access token. await this.tokenService.setTokens( tokenResponse.accessToken, - tokenResponse.refreshToken, vaultTimeoutAction as VaultTimeoutAction, vaultTimeout, + tokenResponse.refreshToken, // Note: CLI login via API key sends undefined for refresh token. ); await this.stateService.addAccount( @@ -204,7 +193,6 @@ export abstract class LoginStrategy { tokens: { ...new AccountTokens(), }, - keys: accountKeys, adminAuthRequest: adminAuthRequest?.toJSON(), }), ); diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index be93d39ebc4..d3de3ea6bac 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -37,15 +37,11 @@ export class PasswordLoginStrategyData implements LoginStrategyData { /** User's entered email obtained pre-login. Always present in MP login. */ userEnteredEmail: string; - + /** If 2fa is required, token is returned to bypass captcha */ captchaBypassToken?: string; - /** - * The local version of the user's master key hash - */ + /** The local version of the user's master key hash */ localMasterKeyHash: string; - /** - * The user's master key - */ + /** The user's master key */ masterKey: MasterKey; /** * Tracks if the user needs to update their password due to @@ -63,14 +59,12 @@ export class PasswordLoginStrategyData implements LoginStrategyData { } export class PasswordLoginStrategy extends LoginStrategy { - /** - * The email address of the user attempting to log in. - */ + /** The email address of the user attempting to log in. */ email$: Observable; - /** - * The master key hash of the user attempting to log in. - */ - masterKeyHash$: Observable; + /** The master key hash used for authentication */ + serverMasterKeyHash$: Observable; + /** The local master key hash we store client side */ + localMasterKeyHash$: Observable; protected cache: BehaviorSubject; @@ -107,7 +101,10 @@ export class PasswordLoginStrategy extends LoginStrategy { this.cache = new BehaviorSubject(data); this.email$ = this.cache.pipe(map((state) => state.tokenRequest.email)); - this.masterKeyHash$ = this.cache.pipe(map((state) => state.localMasterKeyHash)); + this.serverMasterKeyHash$ = this.cache.pipe( + map((state) => state.tokenRequest.masterPasswordHash), + ); + this.localMasterKeyHash$ = this.cache.pipe(map((state) => state.localMasterKeyHash)); } override async logIn(credentials: PasswordLoginCredentials) { @@ -123,11 +120,14 @@ export class PasswordLoginStrategy extends LoginStrategy { data.masterKey, HashPurpose.LocalAuthorization, ); - const masterKeyHash = await this.cryptoService.hashMasterKey(masterPassword, data.masterKey); + const serverMasterKeyHash = await this.cryptoService.hashMasterKey( + masterPassword, + data.masterKey, + ); data.tokenRequest = new PasswordTokenRequest( email, - masterKeyHash, + serverMasterKeyHash, captchaToken, await this.buildTwoFactor(twoFactor, email), await this.buildDeviceRequest(), @@ -171,10 +171,10 @@ export class PasswordLoginStrategy extends LoginStrategy { twoFactor: TokenTwoFactorRequest, captchaResponse: string, ): Promise { - this.cache.next({ - ...this.cache.value, - captchaBypassToken: captchaResponse ?? this.cache.value.captchaBypassToken, - }); + const data = this.cache.value; + data.tokenRequest.captchaResponse = captchaResponse ?? data.captchaBypassToken; + this.cache.next(data); + const result = await super.logInTwoFactor(twoFactor); // 2FA was successful, save the force update password options with the state service if defined diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index 04f158d30a9..7745104bd15 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -20,6 +20,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { InternalUserDecryptionOptionsServiceAbstraction, @@ -284,7 +285,8 @@ export class SsoLoginStrategy extends LoginStrategy { if (await this.cryptoService.hasUserKey()) { // Now that we have a decrypted user key in memory, we can check if we // need to establish trust on the current device - await this.deviceTrustCryptoService.trustDeviceIfRequired(); + const userId = (await this.stateService.getUserId()) as UserId; + await this.deviceTrustCryptoService.trustDeviceIfRequired(userId); // if we successfully decrypted the user key, we can delete the admin auth request out of state // TODO: eventually we post and clean up DB as well once consumed on client @@ -298,7 +300,9 @@ export class SsoLoginStrategy extends LoginStrategy { private async trySetUserKeyWithDeviceKey(tokenResponse: IdentityTokenResponse): Promise { const trustedDeviceOption = tokenResponse.userDecryptionOptions?.trustedDeviceOption; - const deviceKey = await this.deviceTrustCryptoService.getDeviceKey(); + const userId = (await this.stateService.getUserId()) as UserId; + + const deviceKey = await this.deviceTrustCryptoService.getDeviceKey(userId); const encDevicePrivateKey = trustedDeviceOption?.encryptedPrivateKey; const encUserKey = trustedDeviceOption?.encryptedUserKey; @@ -307,6 +311,7 @@ export class SsoLoginStrategy extends LoginStrategy { } const userKey = await this.deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + userId, encDevicePrivateKey, encUserKey, deviceKey, diff --git a/libs/auth/src/common/models/domain/user-decryption-options.ts b/libs/auth/src/common/models/domain/user-decryption-options.ts index c600c8be476..ca4046f36e8 100644 --- a/libs/auth/src/common/models/domain/user-decryption-options.ts +++ b/libs/auth/src/common/models/domain/user-decryption-options.ts @@ -15,11 +15,14 @@ export class KeyConnectorUserDecryptionOption { /** * Initializes a new instance of the KeyConnectorUserDecryptionOption from a response object. * @param response The key connector user decryption option response object. - * @returns A new instance of the KeyConnectorUserDecryptionOption. Will initialize even if the response is nullish. + * @returns A new instance of the KeyConnectorUserDecryptionOption or undefined if `response` is nullish. */ static fromResponse( response: KeyConnectorUserDecryptionOptionResponse, - ): KeyConnectorUserDecryptionOption { + ): KeyConnectorUserDecryptionOption | undefined { + if (response == null) { + return undefined; + } const options = new KeyConnectorUserDecryptionOption(); options.keyConnectorUrl = response?.keyConnectorUrl ?? null; return options; @@ -28,11 +31,14 @@ export class KeyConnectorUserDecryptionOption { /** * Initializes a new instance of a KeyConnectorUserDecryptionOption from a JSON object. * @param obj JSON object to deserialize. - * @returns A new instance of the KeyConnectorUserDecryptionOption. Will initialize even if the JSON object is nullish. + * @returns A new instance of the KeyConnectorUserDecryptionOption or undefined if `obj` is nullish. */ static fromJSON( obj: Jsonify, - ): KeyConnectorUserDecryptionOption { + ): KeyConnectorUserDecryptionOption | undefined { + if (obj == null) { + return undefined; + } return Object.assign(new KeyConnectorUserDecryptionOption(), obj); } } @@ -52,11 +58,14 @@ export class TrustedDeviceUserDecryptionOption { /** * Initializes a new instance of the TrustedDeviceUserDecryptionOption from a response object. * @param response The trusted device user decryption option response object. - * @returns A new instance of the TrustedDeviceUserDecryptionOption. Will initialize even if the response is nullish. + * @returns A new instance of the TrustedDeviceUserDecryptionOption or undefined if `response` is nullish. */ static fromResponse( response: TrustedDeviceUserDecryptionOptionResponse, - ): TrustedDeviceUserDecryptionOption { + ): TrustedDeviceUserDecryptionOption | undefined { + if (response == null) { + return undefined; + } const options = new TrustedDeviceUserDecryptionOption(); options.hasAdminApproval = response?.hasAdminApproval ?? false; options.hasLoginApprovingDevice = response?.hasLoginApprovingDevice ?? false; @@ -67,11 +76,14 @@ export class TrustedDeviceUserDecryptionOption { /** * Initializes a new instance of the TrustedDeviceUserDecryptionOption from a JSON object. * @param obj JSON object to deserialize. - * @returns A new instance of the TrustedDeviceUserDecryptionOption. Will initialize even if the JSON object is nullish. + * @returns A new instance of the TrustedDeviceUserDecryptionOption or undefined if `obj` is nullish. */ static fromJSON( obj: Jsonify, - ): TrustedDeviceUserDecryptionOption { + ): TrustedDeviceUserDecryptionOption | undefined { + if (obj == null) { + return undefined; + } return Object.assign(new TrustedDeviceUserDecryptionOption(), obj); } } diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts index b1971f6b526..80d00b2a01e 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts @@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; +import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -30,6 +31,22 @@ describe("AuthRequestService", () => { mockPrivateKey = new Uint8Array(64); }); + describe("authRequestPushNotification$", () => { + it("should emit when sendAuthRequestPushNotification is called", () => { + const notification = { + id: "PUSH_NOTIFICATION", + userId: "USER_ID", + } as AuthRequestPushNotification; + + const spy = jest.fn(); + sut.authRequestPushNotification$.subscribe(spy); + + sut.sendAuthRequestPushNotification(notification); + + expect(spy).toHaveBeenCalledWith("PUSH_NOTIFICATION"); + }); + }); + describe("approveOrDenyAuthRequest", () => { beforeEach(() => { cryptoService.rsaEncrypt.mockResolvedValue({ diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.ts b/libs/auth/src/common/services/auth-request/auth-request.service.ts index ab33780fe6c..eb39659f53f 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.ts @@ -1,6 +1,9 @@ +import { Observable, Subject } from "rxjs"; + import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PasswordlessAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-auth.request"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; +import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -11,12 +14,17 @@ import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { AuthRequestServiceAbstraction } from "../../abstractions/auth-request.service.abstraction"; export class AuthRequestService implements AuthRequestServiceAbstraction { + private authRequestPushNotificationSubject = new Subject(); + authRequestPushNotification$: Observable; + constructor( private appIdService: AppIdService, private cryptoService: CryptoService, private apiService: ApiService, private stateService: StateService, - ) {} + ) { + this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable(); + } async approveOrDenyAuthRequest( approve: boolean, @@ -126,4 +134,10 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { masterKeyHash, }; } + + sendAuthRequestPushNotification(notification: AuthRequestPushNotification): void { + if (notification.id != null) { + this.authRequestPushNotificationSubject.next(notification.id); + } + } } diff --git a/libs/auth/src/common/services/index.ts b/libs/auth/src/common/services/index.ts index 12215cf6b4d..5a0fc083dd7 100644 --- a/libs/auth/src/common/services/index.ts +++ b/libs/auth/src/common/services/index.ts @@ -1,4 +1,5 @@ export * from "./pin-crypto/pin-crypto.service.implementation"; +export * from "./login-email/login-email.service"; export * from "./login-strategies/login-strategy.service"; export * from "./user-decryption-options/user-decryption-options.service"; export * from "./auth-request/auth-request.service"; diff --git a/libs/auth/src/common/services/login-email/login-email.service.ts b/libs/auth/src/common/services/login-email/login-email.service.ts new file mode 100644 index 00000000000..171af07430e --- /dev/null +++ b/libs/auth/src/common/services/login-email/login-email.service.ts @@ -0,0 +1,52 @@ +import { Observable } from "rxjs"; + +import { + GlobalState, + KeyDefinition, + LOGIN_EMAIL_DISK, + StateProvider, +} from "../../../../../common/src/platform/state"; +import { LoginEmailServiceAbstraction } from "../../abstractions/login-email.service"; + +const STORED_EMAIL = new KeyDefinition(LOGIN_EMAIL_DISK, "storedEmail", { + deserializer: (value: string) => value, +}); + +export class LoginEmailService implements LoginEmailServiceAbstraction { + private email: string; + private rememberEmail: boolean; + + private readonly storedEmailState: GlobalState; + storedEmail$: Observable; + + constructor(private stateProvider: StateProvider) { + this.storedEmailState = this.stateProvider.getGlobal(STORED_EMAIL); + this.storedEmail$ = this.storedEmailState.state$; + } + + getEmail() { + return this.email; + } + + setEmail(email: string) { + this.email = email; + } + + getRememberEmail() { + return this.rememberEmail; + } + + setRememberEmail(value: boolean) { + this.rememberEmail = value; + } + + clearValues() { + this.email = null; + this.rememberEmail = null; + } + + async saveEmailSettings() { + await this.storedEmailState.update(() => (this.rememberEmail ? this.email : null)); + this.clearValues(); + } +} diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts index 5dbc3397cf4..b55f38af7f6 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts @@ -1,7 +1,6 @@ import { combineLatestWith, distinctUntilChanged, - filter, firstValueFrom, map, Observable, @@ -23,7 +22,6 @@ import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { PreloginRequest } from "@bitwarden/common/models/request/prelogin.request"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; -import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -81,8 +79,6 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { >; currentAuthType$: Observable; - // TODO: move to auth request service - authRequestPushNotification$: Observable; constructor( protected cryptoService: CryptoService, @@ -114,9 +110,6 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { ); this.currentAuthType$ = this.currentAuthnTypeState.state$; - this.authRequestPushNotification$ = this.authRequestPushNotificationState.state$.pipe( - filter((id) => id != null), - ); this.loginStrategy$ = this.currentAuthnTypeState.state$.pipe( distinctUntilChanged(), combineLatestWith(this.loginStrategyCacheState.state$), @@ -137,8 +130,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { async getMasterPasswordHash(): Promise { const strategy = await firstValueFrom(this.loginStrategy$); - if ("masterKeyHash$" in strategy) { - return await firstValueFrom(strategy.masterKeyHash$); + if ("serverMasterKeyHash$" in strategy) { + return await firstValueFrom(strategy.serverMasterKeyHash$); } return null; } @@ -256,13 +249,6 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { return await this.cryptoService.makeMasterKey(masterPassword, email, kdf, kdfConfig); } - // TODO move to auth request service - async sendAuthRequestPushNotification(notification: AuthRequestPushNotification): Promise { - if (notification.id != null) { - await this.authRequestPushNotificationState.update((_) => notification.id); - } - } - // TODO: move to auth request service async passwordlessLogin( id: string, diff --git a/libs/common/spec/matchers/to-almost-equal.spec.ts b/libs/common/spec/matchers/to-almost-equal.spec.ts new file mode 100644 index 00000000000..59225451372 --- /dev/null +++ b/libs/common/spec/matchers/to-almost-equal.spec.ts @@ -0,0 +1,54 @@ +describe("toAlmostEqual custom matcher", () => { + it("matches identical Dates", () => { + const date = new Date(); + expect(date).toAlmostEqual(date); + }); + + it("matches when older but within default ms", () => { + const date = new Date(); + const olderDate = new Date(date.getTime() - 5); + expect(date).toAlmostEqual(olderDate); + }); + + it("matches when newer but within default ms", () => { + const date = new Date(); + const olderDate = new Date(date.getTime() + 5); + expect(date).toAlmostEqual(olderDate); + }); + + it("doesn't match if older than default ms", () => { + const date = new Date(); + const olderDate = new Date(date.getTime() - 11); + expect(date).not.toAlmostEqual(olderDate); + }); + + it("doesn't match if newer than default ms", () => { + const date = new Date(); + const olderDate = new Date(date.getTime() + 11); + expect(date).not.toAlmostEqual(olderDate); + }); + + it("matches when older but within custom ms", () => { + const date = new Date(); + const olderDate = new Date(date.getTime() - 15); + expect(date).toAlmostEqual(olderDate, 20); + }); + + it("matches when newer but within custom ms", () => { + const date = new Date(); + const olderDate = new Date(date.getTime() + 15); + expect(date).toAlmostEqual(olderDate, 20); + }); + + it("doesn't match if older than custom ms", () => { + const date = new Date(); + const olderDate = new Date(date.getTime() - 21); + expect(date).not.toAlmostEqual(olderDate, 20); + }); + + it("doesn't match if newer than custom ms", () => { + const date = new Date(); + const olderDate = new Date(date.getTime() + 21); + expect(date).not.toAlmostEqual(olderDate, 20); + }); +}); diff --git a/libs/common/spec/matchers/to-almost-equal.ts b/libs/common/spec/matchers/to-almost-equal.ts new file mode 100644 index 00000000000..ba5aacc9b33 --- /dev/null +++ b/libs/common/spec/matchers/to-almost-equal.ts @@ -0,0 +1,20 @@ +/** + * Matches the expected date within an optional ms precision + * @param received The received date + * @param expected The expected date + * @param msPrecision The optional precision in milliseconds + */ +export const toAlmostEqual: jest.CustomMatcher = function ( + received: Date, + expected: Date, + msPrecision: number = 10, +) { + const receivedTime = received.getTime(); + const expectedTime = expected.getTime(); + const difference = Math.abs(receivedTime - expectedTime); + return { + pass: difference <= msPrecision, + message: () => + `expected ${received} to be within ${msPrecision}ms of ${expected} (actual difference: ${difference}ms)`, + }; +}; diff --git a/libs/common/spec/observable-tracker.ts b/libs/common/spec/observable-tracker.ts new file mode 100644 index 00000000000..a6f3e6a879f --- /dev/null +++ b/libs/common/spec/observable-tracker.ts @@ -0,0 +1,86 @@ +import { Observable, Subscription, firstValueFrom, throwError, timeout } from "rxjs"; + +/** Test class to enable async awaiting of observable emissions */ +export class ObservableTracker { + private subscription: Subscription; + emissions: T[] = []; + constructor(private observable: Observable) { + this.emissions = this.trackEmissions(observable); + } + + /** Unsubscribes from the observable */ + unsubscribe() { + this.subscription.unsubscribe(); + } + + /** + * Awaits the next emission from the observable, or throws if the timeout is exceeded + * @param msTimeout The maximum time to wait for another emission before throwing + */ + async expectEmission(msTimeout = 50) { + await firstValueFrom( + this.observable.pipe( + timeout({ + first: msTimeout, + with: () => throwError(() => new Error("Timeout exceeded waiting for another emission.")), + }), + ), + ); + } + + /** Awaits until the the total number of emissions observed by this tracker equals or exceeds {@link count} + * @param count The number of emissions to wait for + */ + async pauseUntilReceived(count: number, msTimeout = 50): Promise { + for (let i = 0; i < count - this.emissions.length; i++) { + await this.expectEmission(msTimeout); + } + return this.emissions; + } + + private trackEmissions(observable: Observable): T[] { + const emissions: T[] = []; + this.subscription = observable.subscribe((value) => { + switch (value) { + case undefined: + case null: + emissions.push(value); + return; + default: + // process by type + break; + } + + switch (typeof value) { + case "string": + case "number": + case "boolean": + emissions.push(value); + break; + case "symbol": + // Cheating types to make symbols work at all + emissions.push(value.toString() as T); + break; + default: { + emissions.push(clone(value)); + } + } + }); + return emissions; + } +} +function clone(value: any): any { + if (global.structuredClone != undefined) { + return structuredClone(value); + } else { + return JSON.parse(JSON.stringify(value)); + } +} + +/** A test helper that builds an @see{@link ObservableTracker}, which can be used to assert things about the + * emissions of the given observable + * @param observable The observable to track + */ +export function subscribeTo(observable: Observable) { + return new ObservableTracker(observable); +} diff --git a/libs/common/src/auth/abstractions/anonymous-hub.service.ts b/libs/common/src/auth/abstractions/anonymous-hub.service.ts index 43bdabd512c..e108dccbb60 100644 --- a/libs/common/src/auth/abstractions/anonymous-hub.service.ts +++ b/libs/common/src/auth/abstractions/anonymous-hub.service.ts @@ -1,4 +1,4 @@ export abstract class AnonymousHubService { - createHubConnection: (token: string) => void; - stopHubConnection: () => void; + createHubConnection: (token: string) => Promise; + stopHubConnection: () => Promise; } diff --git a/libs/common/src/auth/abstractions/auth.service.ts b/libs/common/src/auth/abstractions/auth.service.ts index dc51e2fdb0f..de08dbd4e99 100644 --- a/libs/common/src/auth/abstractions/auth.service.ts +++ b/libs/common/src/auth/abstractions/auth.service.ts @@ -1,6 +1,18 @@ +import { Observable } from "rxjs"; + +import { UserId } from "../../types/guid"; import { AuthenticationStatus } from "../enums/authentication-status"; export abstract class AuthService { - getAuthStatus: (userId?: string) => Promise; - logOut: (callback: () => void) => void; + /** Authentication status for the active user */ + abstract activeAccountStatus$: Observable; + /** + * Returns an observable authentication status for the given user id. + * @note userId is a required parameter, null values will always return `AuthenticationStatus.LoggedOut` + * @param userId The user id to check for an access token. + */ + abstract authStatusFor$(userId: UserId): Observable; + /** @deprecated use {@link activeAccountStatus$} instead */ + abstract getAuthStatus: (userId?: string) => Promise; + abstract logOut: (callback: () => void) => void; } diff --git a/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts b/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts index 415355cfc77..53fe2140353 100644 --- a/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts @@ -1,6 +1,7 @@ import { Observable } from "rxjs"; import { EncString } from "../../platform/models/domain/enc-string"; +import { UserId } from "../../types/guid"; import { DeviceKey, UserKey } from "../../types/key"; import { DeviceResponse } from "../abstractions/devices/responses/device.response"; @@ -10,17 +11,24 @@ export abstract class DeviceTrustCryptoServiceAbstraction { * @description Retrieves the users choice to trust the device which can only happen after decryption * Note: this value should only be used once and then reset */ - getShouldTrustDevice: () => Promise; - setShouldTrustDevice: (value: boolean) => Promise; + getShouldTrustDevice: (userId: UserId) => Promise; + setShouldTrustDevice: (userId: UserId, value: boolean) => Promise; - trustDeviceIfRequired: () => Promise; + trustDeviceIfRequired: (userId: UserId) => Promise; - trustDevice: () => Promise; - getDeviceKey: () => Promise; + trustDevice: (userId: UserId) => Promise; + + /** Retrieves the device key if it exists from state or secure storage if supported for the active user. */ + getDeviceKey: (userId: UserId) => Promise; decryptUserKeyWithDeviceKey: ( + userId: UserId, encryptedDevicePrivateKey: EncString, encryptedUserKey: EncString, - deviceKey?: DeviceKey, + deviceKey: DeviceKey, ) => Promise; - rotateDevicesTrust: (newUserKey: UserKey, masterPasswordHash: string) => Promise; + rotateDevicesTrust: ( + userId: UserId, + newUserKey: UserKey, + masterPasswordHash: string, + ) => Promise; } diff --git a/libs/common/src/auth/abstractions/key-connector.service.ts b/libs/common/src/auth/abstractions/key-connector.service.ts index b7c8d5d0d0b..36f413d70c7 100644 --- a/libs/common/src/auth/abstractions/key-connector.service.ts +++ b/libs/common/src/auth/abstractions/key-connector.service.ts @@ -15,5 +15,4 @@ export abstract class KeyConnectorService { setConvertAccountRequired: (status: boolean) => Promise; getConvertAccountRequired: () => Promise; removeConvertAccountRequired: () => Promise; - clear: () => Promise; } diff --git a/libs/common/src/auth/abstractions/login.service.ts b/libs/common/src/auth/abstractions/login.service.ts deleted file mode 100644 index 9a884fd5d1c..00000000000 --- a/libs/common/src/auth/abstractions/login.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -export abstract class LoginService { - getEmail: () => string; - getRememberEmail: () => boolean; - setEmail: (value: string) => void; - setRememberEmail: (value: boolean) => void; - clearValues: () => void; - saveEmailSettings: () => Promise; -} diff --git a/libs/common/src/auth/abstractions/token.service.ts b/libs/common/src/auth/abstractions/token.service.ts index d2358314d79..75bb3838828 100644 --- a/libs/common/src/auth/abstractions/token.service.ts +++ b/libs/common/src/auth/abstractions/token.service.ts @@ -1,8 +1,15 @@ +import { Observable } from "rxjs"; + import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { UserId } from "../../types/guid"; import { DecodedAccessToken } from "../services/token.service"; export abstract class TokenService { + /** + * Returns an observable that emits a boolean indicating whether the user has an access token. + * @param userId The user id to check for an access token. + */ + abstract hasAccessToken$(userId: UserId): Observable; /** * Sets the access token, refresh token, API Key Client ID, and API Key Client Secret in memory or disk * based on the given vaultTimeoutAction and vaultTimeout and the derived access token user id. @@ -10,17 +17,18 @@ export abstract class TokenService { * Note 2: this method also enforces always setting the access token and the refresh token together as * we can retrieve the user id required to set the refresh token from the access token for efficiency. * @param accessToken The access token to set. - * @param refreshToken The refresh token to set. - * @param clientIdClientSecret The API Key Client ID and Client Secret to set. * @param vaultTimeoutAction The action to take when the vault times out. * @param vaultTimeout The timeout for the vault. + * @param refreshToken The optional refresh token to set. Note: this is undefined when using the CLI Login Via API Key flow + * @param clientIdClientSecret The API Key Client ID and Client Secret to set. + * * @returns A promise that resolves when the tokens have been set. */ setTokens: ( accessToken: string, - refreshToken: string, vaultTimeoutAction: VaultTimeoutAction, vaultTimeout: number | null, + refreshToken?: string, clientIdClientSecret?: [string, string], ) => Promise; diff --git a/libs/common/src/auth/services/anonymous-hub.service.ts b/libs/common/src/auth/services/anonymous-hub.service.ts index fe8ae641832..747fbc39178 100644 --- a/libs/common/src/auth/services/anonymous-hub.service.ts +++ b/libs/common/src/auth/services/anonymous-hub.service.ts @@ -7,13 +7,13 @@ import { import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack"; import { firstValueFrom } from "rxjs"; -import { LoginStrategyServiceAbstraction } from "../../../../auth/src/common/abstractions/login-strategy.service"; +import { AuthRequestServiceAbstraction } from "../../../../auth/src/common/abstractions"; +import { NotificationType } from "../../enums"; import { AuthRequestPushNotification, NotificationResponse, } from "../../models/response/notification.response"; import { EnvironmentService } from "../../platform/abstractions/environment.service"; -import { LogService } from "../../platform/abstractions/log.service"; import { AnonymousHubService as AnonymousHubServiceAbstraction } from "../abstractions/anonymous-hub.service"; export class AnonymousHubService implements AnonymousHubServiceAbstraction { @@ -22,8 +22,7 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction { constructor( private environmentService: EnvironmentService, - private loginStrategyService: LoginStrategyServiceAbstraction, - private logService: LogService, + private authRequestService: AuthRequestServiceAbstraction, ) {} async createHubConnection(token: string) { @@ -37,26 +36,25 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction { .withHubProtocol(new MessagePackHubProtocol() as IHubProtocol) .build(); - this.anonHubConnection.start().catch((error) => this.logService.error(error)); + await this.anonHubConnection.start(); this.anonHubConnection.on("AuthRequestResponseRecieved", (data: any) => { - // 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.ProcessNotification(new NotificationResponse(data)); }); } - stopHubConnection() { + async stopHubConnection() { if (this.anonHubConnection) { - // 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.anonHubConnection.stop(); + await this.anonHubConnection.stop(); } } - private async ProcessNotification(notification: NotificationResponse) { - await this.loginStrategyService.sendAuthRequestPushNotification( - notification.payload as AuthRequestPushNotification, - ); + private ProcessNotification(notification: NotificationResponse) { + switch (notification.type) { + case NotificationType.AuthRequestResponse: + this.authRequestService.sendAuthRequestPushNotification( + notification.payload as AuthRequestPushNotification, + ); + } } } diff --git a/libs/common/src/auth/services/auth.service.spec.ts b/libs/common/src/auth/services/auth.service.spec.ts new file mode 100644 index 00000000000..07e38def4b1 --- /dev/null +++ b/libs/common/src/auth/services/auth.service.spec.ts @@ -0,0 +1,161 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { + FakeAccountService, + makeStaticByteArray, + mockAccountServiceWith, + trackEmissions, +} from "../../../spec"; +import { ApiService } from "../../abstractions/api.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; +import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { UserId } from "../../types/guid"; +import { UserKey } from "../../types/key"; +import { TokenService } from "../abstractions/token.service"; +import { AuthenticationStatus } from "../enums/authentication-status"; + +import { AuthService } from "./auth.service"; + +describe("AuthService", () => { + let sut: AuthService; + + let accountService: FakeAccountService; + let messagingService: MockProxy; + let cryptoService: MockProxy; + let apiService: MockProxy; + let stateService: MockProxy; + let tokenService: MockProxy; + + const userId = Utils.newGuid() as UserId; + const userKey = new SymmetricCryptoKey(makeStaticByteArray(32) as Uint8Array) as UserKey; + + beforeEach(() => { + accountService = mockAccountServiceWith(userId); + messagingService = mock(); + cryptoService = mock(); + apiService = mock(); + stateService = mock(); + tokenService = mock(); + + sut = new AuthService( + accountService, + messagingService, + cryptoService, + apiService, + stateService, + tokenService, + ); + }); + + describe("activeAccountStatus$", () => { + const accountInfo = { + status: AuthenticationStatus.Unlocked, + id: userId, + email: "email", + name: "name", + }; + + beforeEach(() => { + accountService.activeAccountSubject.next(accountInfo); + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined)); + }); + + it("emits LoggedOut when there is no active account", async () => { + accountService.activeAccountSubject.next(undefined); + + expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual( + AuthenticationStatus.LoggedOut, + ); + }); + + it("emits LoggedOut when there is no access token", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(false)); + + expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual( + AuthenticationStatus.LoggedOut, + ); + }); + + it("emits LoggedOut when there is no access token but has a user key", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(false)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey)); + + expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual( + AuthenticationStatus.LoggedOut, + ); + }); + + it("emits Locked when there is an access token and no user key", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined)); + + expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(AuthenticationStatus.Locked); + }); + + it("emits Unlocked when there is an access token and user key", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey)); + + expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(AuthenticationStatus.Unlocked); + }); + + it("follows the current active user", async () => { + const accountInfo2 = { + status: AuthenticationStatus.Unlocked, + id: Utils.newGuid() as UserId, + email: "email2", + name: "name2", + }; + + const emissions = trackEmissions(sut.activeAccountStatus$); + + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey)); + accountService.activeAccountSubject.next(accountInfo2); + + expect(emissions).toEqual([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked]); + }); + }); + + describe("authStatusFor$", () => { + beforeEach(() => { + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined)); + }); + + it("emits LoggedOut when userId is null", async () => { + expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual( + AuthenticationStatus.LoggedOut, + ); + }); + + it("emits LoggedOut when there is no access token", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(false)); + + expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual( + AuthenticationStatus.LoggedOut, + ); + }); + + it("emits Locked when there is an access token and no user key", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined)); + + expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual(AuthenticationStatus.Locked); + }); + + it("emits Unlocked when there is an access token and user key", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey)); + + expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual( + AuthenticationStatus.Unlocked, + ); + }); + }); +}); diff --git a/libs/common/src/auth/services/auth.service.ts b/libs/common/src/auth/services/auth.service.ts index 14d49956a43..de5eb66c061 100644 --- a/libs/common/src/auth/services/auth.service.ts +++ b/libs/common/src/auth/services/auth.service.ts @@ -1,18 +1,67 @@ +import { + Observable, + combineLatest, + distinctUntilChanged, + map, + of, + shareReplay, + switchMap, +} from "rxjs"; + import { ApiService } from "../../abstractions/api.service"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { StateService } from "../../platform/abstractions/state.service"; import { KeySuffixOptions } from "../../platform/enums"; +import { UserId } from "../../types/guid"; +import { AccountService } from "../abstractions/account.service"; import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service"; +import { TokenService } from "../abstractions/token.service"; import { AuthenticationStatus } from "../enums/authentication-status"; export class AuthService implements AuthServiceAbstraction { + activeAccountStatus$: Observable; + constructor( + protected accountService: AccountService, protected messagingService: MessagingService, protected cryptoService: CryptoService, protected apiService: ApiService, protected stateService: StateService, - ) {} + private tokenService: TokenService, + ) { + this.activeAccountStatus$ = this.accountService.activeAccount$.pipe( + map((account) => account?.id), + switchMap((userId) => { + return this.authStatusFor$(userId); + }), + ); + } + + authStatusFor$(userId: UserId): Observable { + if (userId == null) { + return of(AuthenticationStatus.LoggedOut); + } + + return combineLatest([ + this.cryptoService.getInMemoryUserKeyFor$(userId), + this.tokenService.hasAccessToken$(userId), + ]).pipe( + map(([userKey, hasAccessToken]) => { + if (!hasAccessToken) { + return AuthenticationStatus.LoggedOut; + } + + if (!userKey) { + return AuthenticationStatus.Locked; + } + + return AuthenticationStatus.Unlocked; + }), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: false }), + ); + } async getAuthStatus(userId?: string): Promise { // If we don't have an access token or userId, we're logged out diff --git a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts index 71f83f07c3b..e65c5cd499a 100644 --- a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts +++ b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts @@ -9,9 +9,13 @@ import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; -import { StateService } from "../../platform/abstractions/state.service"; +import { AbstractStorageService } from "../../platform/abstractions/storage.service"; +import { StorageLocation } from "../../platform/enums"; import { EncString } from "../../platform/models/domain/enc-string"; +import { StorageOptions } from "../../platform/models/domain/storage-options"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { DEVICE_TRUST_DISK_LOCAL, KeyDefinition, StateProvider } from "../../platform/state"; +import { UserId } from "../../types/guid"; import { UserKey, DeviceKey } from "../../types/key"; import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { DeviceResponse } from "../abstractions/devices/responses/device.response"; @@ -22,7 +26,25 @@ import { UpdateDevicesTrustRequest, } from "../models/request/update-devices-trust.request"; +/** Uses disk storage so that the device key can persist after log out and tab removal. */ +export const DEVICE_KEY = new KeyDefinition(DEVICE_TRUST_DISK_LOCAL, "deviceKey", { + deserializer: (deviceKey) => SymmetricCryptoKey.fromJSON(deviceKey) as DeviceKey, +}); + +/** Uses disk storage so that the shouldTrustDevice bool can persist across login. */ +export const SHOULD_TRUST_DEVICE = new KeyDefinition( + DEVICE_TRUST_DISK_LOCAL, + "shouldTrustDevice", + { + deserializer: (shouldTrustDevice) => shouldTrustDevice, + }, +); + export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstraction { + private readonly platformSupportsSecureStorage = + this.platformUtilsService.supportsSecureStorage(); + private readonly deviceKeySecureStorageKey: string = "_deviceKey"; + supportsDeviceTrust$: Observable; constructor( @@ -30,11 +52,12 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac private cryptoFunctionService: CryptoFunctionService, private cryptoService: CryptoService, private encryptService: EncryptService, - private stateService: StateService, private appIdService: AppIdService, private devicesApiService: DevicesApiServiceAbstraction, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, + private stateProvider: StateProvider, + private secureStorageService: AbstractStorageService, private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, ) { this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe( @@ -46,24 +69,44 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac * @description Retrieves the users choice to trust the device which can only happen after decryption * Note: this value should only be used once and then reset */ - async getShouldTrustDevice(): Promise { - return await this.stateService.getShouldTrustDevice(); + async getShouldTrustDevice(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot get should trust device."); + } + + const shouldTrustDevice = await firstValueFrom( + this.stateProvider.getUserState$(SHOULD_TRUST_DEVICE, userId), + ); + + return shouldTrustDevice; } - async setShouldTrustDevice(value: boolean): Promise { - await this.stateService.setShouldTrustDevice(value); + async setShouldTrustDevice(userId: UserId, value: boolean): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot set should trust device."); + } + + await this.stateProvider.setUserState(SHOULD_TRUST_DEVICE, value, userId); } - async trustDeviceIfRequired(): Promise { - const shouldTrustDevice = await this.getShouldTrustDevice(); + async trustDeviceIfRequired(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot trust device if required."); + } + + const shouldTrustDevice = await this.getShouldTrustDevice(userId); if (shouldTrustDevice) { - await this.trustDevice(); + await this.trustDevice(userId); // reset the trust choice - await this.setShouldTrustDevice(false); + await this.setShouldTrustDevice(userId, false); } } - async trustDevice(): Promise { + async trustDevice(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot trust device."); + } + // Attempt to get user key const userKey: UserKey = await this.cryptoService.getUserKey(); @@ -104,15 +147,23 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac ); // store device key in local/secure storage if enc keys posted to server successfully - await this.setDeviceKey(deviceKey); + await this.setDeviceKey(userId, deviceKey); this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted")); return deviceResponse; } - async rotateDevicesTrust(newUserKey: UserKey, masterPasswordHash: string): Promise { - const currentDeviceKey = await this.getDeviceKey(); + async rotateDevicesTrust( + userId: UserId, + newUserKey: UserKey, + masterPasswordHash: string, + ): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot rotate device's trust."); + } + + const currentDeviceKey = await this.getDeviceKey(userId); if (currentDeviceKey == null) { // If the current device doesn't have a device key available to it, then we can't // rotate any trust at all, so early return. @@ -165,26 +216,59 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac await this.devicesApiService.updateTrust(trustRequest, deviceIdentifier); } - async getDeviceKey(): Promise { - return await this.stateService.getDeviceKey(); + async getDeviceKey(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot get device key."); + } + + if (this.platformSupportsSecureStorage) { + const deviceKeyB64 = await this.secureStorageService.get< + ReturnType + >(`${userId}${this.deviceKeySecureStorageKey}`, this.getSecureStorageOptions(userId)); + + const deviceKey = SymmetricCryptoKey.fromJSON(deviceKeyB64) as DeviceKey; + + return deviceKey; + } + + const deviceKey = await firstValueFrom(this.stateProvider.getUserState$(DEVICE_KEY, userId)); + + return deviceKey; } - private async setDeviceKey(deviceKey: DeviceKey | null): Promise { - await this.stateService.setDeviceKey(deviceKey); + private async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot set device key."); + } + + if (this.platformSupportsSecureStorage) { + await this.secureStorageService.save( + `${userId}${this.deviceKeySecureStorageKey}`, + deviceKey, + this.getSecureStorageOptions(userId), + ); + return; + } + + await this.stateProvider.setUserState(DEVICE_KEY, deviceKey?.toJSON(), userId); } private async makeDeviceKey(): Promise { // Create 512-bit device key - return (await this.keyGenerationService.createKey(512)) as DeviceKey; + const deviceKey = (await this.keyGenerationService.createKey(512)) as DeviceKey; + + return deviceKey; } async decryptUserKeyWithDeviceKey( + userId: UserId, encryptedDevicePrivateKey: EncString, encryptedUserKey: EncString, - deviceKey?: DeviceKey, + deviceKey: DeviceKey, ): Promise { - // If device key provided use it, otherwise try to retrieve from storage - deviceKey ||= await this.getDeviceKey(); + if (!userId) { + throw new Error("UserId is required. Cannot decrypt user key with device key."); + } if (!deviceKey) { // User doesn't have a device key anymore so device is untrusted @@ -207,9 +291,17 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac return new SymmetricCryptoKey(userKey) as UserKey; } catch (e) { // If either decryption effort fails, we want to remove the device key - await this.setDeviceKey(null); + await this.setDeviceKey(userId, null); return null; } } + + private getSecureStorageOptions(userId: UserId): StorageOptions { + return { + storageLocation: StorageLocation.Disk, + useSecureStorage: true, + userId: userId, + }; + } } diff --git a/libs/common/src/auth/services/device-trust-crypto.service.spec.ts b/libs/common/src/auth/services/device-trust-crypto.service.spec.ts index 1d33223dddb..af147b3481d 100644 --- a/libs/common/src/auth/services/device-trust-crypto.service.spec.ts +++ b/libs/common/src/auth/services/device-trust-crypto.service.spec.ts @@ -4,6 +4,9 @@ import { BehaviorSubject, of } from "rxjs"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { UserDecryptionOptions } from "../../../../auth/src/common/models/domain/user-decryption-options"; +import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; +import { FakeActiveUserState } from "../../../spec/fake-state"; +import { FakeStateProvider } from "../../../spec/fake-state-provider"; import { DeviceType } from "../../enums"; import { AppIdService } from "../../platform/abstractions/app-id.service"; import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service"; @@ -12,18 +15,26 @@ import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; -import { StateService } from "../../platform/abstractions/state.service"; +import { AbstractStorageService } from "../../platform/abstractions/storage.service"; +import { StorageLocation } from "../../platform/enums"; import { EncryptionType } from "../../platform/enums/encryption-type.enum"; +import { Utils } from "../../platform/misc/utils"; import { EncString } from "../../platform/models/domain/enc-string"; +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 { DeviceKey, UserKey } from "../../types/key"; import { DeviceResponse } from "../abstractions/devices/responses/device.response"; import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction"; import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request"; import { ProtectedDeviceResponse } from "../models/response/protected-device.response"; -import { DeviceTrustCryptoService } from "./device-trust-crypto.service.implementation"; +import { + SHOULD_TRUST_DEVICE, + DEVICE_KEY, + DeviceTrustCryptoService, +} from "./device-trust-crypto.service.implementation"; describe("deviceTrustCryptoService", () => { let deviceTrustCryptoService: DeviceTrustCryptoService; @@ -32,33 +43,34 @@ describe("deviceTrustCryptoService", () => { const cryptoFunctionService = mock(); const cryptoService = mock(); const encryptService = mock(); - const stateService = mock(); const appIdService = mock(); const devicesApiService = mock(); const i18nService = mock(); const platformUtilsService = mock(); - const userDecryptionOptionsService = mock(); + const secureStorageService = mock(); + const userDecryptionOptionsService = mock(); const decryptionOptions = new BehaviorSubject(null); + let stateProvider: FakeStateProvider; + + const mockUserId = Utils.newGuid() as UserId; + let accountService: FakeAccountService; + + const deviceKeyPartialSecureStorageKey = "_deviceKey"; + const deviceKeySecureStorageKey = `${mockUserId}${deviceKeyPartialSecureStorageKey}`; + + const secureStorageOptions: StorageOptions = { + storageLocation: StorageLocation.Disk, + useSecureStorage: true, + userId: mockUserId, + }; + beforeEach(() => { jest.clearAllMocks(); - - decryptionOptions.next({} as any); - userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions; - - deviceTrustCryptoService = new DeviceTrustCryptoService( - keyGenerationService, - cryptoFunctionService, - cryptoService, - encryptService, - stateService, - appIdService, - devicesApiService, - i18nService, - platformUtilsService, - userDecryptionOptionsService, - ); + const supportsSecureStorage = false; // default to false; tests will override as needed + // By default all the tests will have a mocked active user in state provider. + deviceTrustCryptoService = createDeviceTrustCryptoService(mockUserId, supportsSecureStorage); }); it("instantiates", () => { @@ -67,27 +79,26 @@ describe("deviceTrustCryptoService", () => { describe("User Trust Device Choice For Decryption", () => { describe("getShouldTrustDevice", () => { - it("gets the user trust device choice for decryption from the state service", async () => { - const stateSvcGetShouldTrustDeviceSpy = jest.spyOn(stateService, "getShouldTrustDevice"); + it("gets the user trust device choice for decryption", async () => { + const newValue = true; - const expectedValue = true; - stateSvcGetShouldTrustDeviceSpy.mockResolvedValue(expectedValue); - const result = await deviceTrustCryptoService.getShouldTrustDevice(); + await stateProvider.setUserState(SHOULD_TRUST_DEVICE, newValue, mockUserId); - expect(stateSvcGetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1); - expect(result).toEqual(expectedValue); + const result = await deviceTrustCryptoService.getShouldTrustDevice(mockUserId); + + expect(result).toEqual(newValue); }); }); describe("setShouldTrustDevice", () => { - it("sets the user trust device choice for decryption in the state service", async () => { - const stateSvcSetShouldTrustDeviceSpy = jest.spyOn(stateService, "setShouldTrustDevice"); + it("sets the user trust device choice for decryption ", async () => { + await stateProvider.setUserState(SHOULD_TRUST_DEVICE, false, mockUserId); const newValue = true; - await deviceTrustCryptoService.setShouldTrustDevice(newValue); + await deviceTrustCryptoService.setShouldTrustDevice(mockUserId, newValue); - expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1); - expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledWith(newValue); + const result = await deviceTrustCryptoService.getShouldTrustDevice(mockUserId); + expect(result).toEqual(newValue); }); }); }); @@ -98,11 +109,11 @@ describe("deviceTrustCryptoService", () => { jest.spyOn(deviceTrustCryptoService, "trustDevice").mockResolvedValue({} as DeviceResponse); jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice").mockResolvedValue(); - await deviceTrustCryptoService.trustDeviceIfRequired(); + await deviceTrustCryptoService.trustDeviceIfRequired(mockUserId); expect(deviceTrustCryptoService.getShouldTrustDevice).toHaveBeenCalledTimes(1); expect(deviceTrustCryptoService.trustDevice).toHaveBeenCalledTimes(1); - expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(false); + expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(mockUserId, false); }); it("should not trust device nor reset when getShouldTrustDevice returns false", async () => { @@ -112,7 +123,7 @@ describe("deviceTrustCryptoService", () => { const trustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "trustDevice"); const setShouldTrustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice"); - await deviceTrustCryptoService.trustDeviceIfRequired(); + await deviceTrustCryptoService.trustDeviceIfRequired(mockUserId); expect(getShouldTrustDeviceSpy).toHaveBeenCalledTimes(1); expect(trustDeviceSpy).not.toHaveBeenCalled(); @@ -126,53 +137,140 @@ describe("deviceTrustCryptoService", () => { describe("getDeviceKey", () => { let existingDeviceKey: DeviceKey; - let stateSvcGetDeviceKeySpy: jest.SpyInstance; + let existingDeviceKeyB64: { keyB64: string }; beforeEach(() => { existingDeviceKey = new SymmetricCryptoKey( new Uint8Array(deviceKeyBytesLength) as CsprngArray, ) as DeviceKey; - stateSvcGetDeviceKeySpy = jest.spyOn(stateService, "getDeviceKey"); + existingDeviceKeyB64 = existingDeviceKey.toJSON(); }); - it("returns null when there is not an existing device key", async () => { - stateSvcGetDeviceKeySpy.mockResolvedValue(null); + describe("Secure Storage not supported", () => { + it("returns null when there is not an existing device key", async () => { + await stateProvider.setUserState(DEVICE_KEY, null, mockUserId); - const deviceKey = await deviceTrustCryptoService.getDeviceKey(); + const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId); - expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1); + expect(deviceKey).toBeNull(); + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); - expect(deviceKey).toBeNull(); + it("returns the device key when there is an existing device key", async () => { + await stateProvider.setUserState(DEVICE_KEY, existingDeviceKey, mockUserId); + + const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId); + + expect(deviceKey).not.toBeNull(); + expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); + expect(deviceKey).toEqual(existingDeviceKey); + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); }); - it("returns the device key when there is an existing device key", async () => { - stateSvcGetDeviceKeySpy.mockResolvedValue(existingDeviceKey); + describe("Secure Storage supported", () => { + beforeEach(() => { + const supportsSecureStorage = true; + deviceTrustCryptoService = createDeviceTrustCryptoService( + mockUserId, + supportsSecureStorage, + ); + }); - const deviceKey = await deviceTrustCryptoService.getDeviceKey(); + it("returns null when there is not an existing device key for the passed in user id", async () => { + secureStorageService.get.mockResolvedValue(null); - expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1); + // Act + const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId); - expect(deviceKey).not.toBeNull(); - expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); - expect(deviceKey).toEqual(existingDeviceKey); + // Assert + expect(deviceKey).toBeNull(); + }); + + it("returns the device key when there is an existing device key for the passed in user id", async () => { + // Arrange + secureStorageService.get.mockResolvedValue(existingDeviceKeyB64); + + // Act + const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId); + + // Assert + expect(deviceKey).not.toBeNull(); + expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); + expect(deviceKey).toEqual(existingDeviceKey); + }); + }); + + it("throws an error when no user id is passed in", async () => { + await expect(deviceTrustCryptoService.getDeviceKey(null)).rejects.toThrow( + "UserId is required. Cannot get device key.", + ); }); }); describe("setDeviceKey", () => { - it("sets the device key in the state service", async () => { - const stateSvcSetDeviceKeySpy = jest.spyOn(stateService, "setDeviceKey"); + describe("Secure Storage not supported", () => { + it("successfully sets the device key in state provider", async () => { + await stateProvider.setUserState(DEVICE_KEY, null, mockUserId); - const deviceKey = new SymmetricCryptoKey( + const newDeviceKey = new SymmetricCryptoKey( + new Uint8Array(deviceKeyBytesLength) as CsprngArray, + ) as DeviceKey; + + // TypeScript will allow calling private methods if the object is of type 'any' + // This is a hacky workaround, but it allows for cleaner tests + await (deviceTrustCryptoService as any).setDeviceKey(mockUserId, newDeviceKey); + + expect(stateProvider.mock.setUserState).toHaveBeenLastCalledWith( + DEVICE_KEY, + newDeviceKey.toJSON(), + mockUserId, + ); + }); + }); + describe("Secure Storage supported", () => { + beforeEach(() => { + const supportsSecureStorage = true; + deviceTrustCryptoService = createDeviceTrustCryptoService( + mockUserId, + supportsSecureStorage, + ); + }); + + it("successfully sets the device key in secure storage", async () => { + // Arrange + await stateProvider.setUserState(DEVICE_KEY, null, mockUserId); + + secureStorageService.get.mockResolvedValue(null); + + const newDeviceKey = new SymmetricCryptoKey( + new Uint8Array(deviceKeyBytesLength) as CsprngArray, + ) as DeviceKey; + + // Act + // TypeScript will allow calling private methods if the object is of type 'any' + // This is a hacky workaround, but it allows for cleaner tests + await (deviceTrustCryptoService as any).setDeviceKey(mockUserId, newDeviceKey); + + // Assert + expect(stateProvider.mock.setUserState).not.toHaveBeenCalledTimes(2); + expect(secureStorageService.save).toHaveBeenCalledWith( + deviceKeySecureStorageKey, + newDeviceKey, + secureStorageOptions, + ); + }); + }); + + it("throws an error when a null user id is passed in", async () => { + const newDeviceKey = new SymmetricCryptoKey( new Uint8Array(deviceKeyBytesLength) as CsprngArray, ) as DeviceKey; - // TypeScript will allow calling private methods if the object is of type 'any' - // This is a hacky workaround, but it allows for cleaner tests - await (deviceTrustCryptoService as any).setDeviceKey(deviceKey); - - expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledTimes(1); - expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledWith(deviceKey); + await expect( + (deviceTrustCryptoService as any).setDeviceKey(null, newDeviceKey), + ).rejects.toThrow("UserId is required. Cannot set device key."); }); }); @@ -300,7 +398,7 @@ describe("deviceTrustCryptoService", () => { }); it("calls the required methods with the correct arguments and returns a DeviceResponse", async () => { - const response = await deviceTrustCryptoService.trustDevice(); + const response = await deviceTrustCryptoService.trustDevice(mockUserId); expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1); expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1); @@ -331,7 +429,7 @@ describe("deviceTrustCryptoService", () => { // setup the spy to return null cryptoSvcGetUserKeySpy.mockResolvedValue(null); // check if the expected error is thrown - await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow( + await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow( "User symmetric key not found", ); @@ -341,7 +439,7 @@ describe("deviceTrustCryptoService", () => { // setup the spy to return undefined cryptoSvcGetUserKeySpy.mockResolvedValue(undefined); // check if the expected error is thrown - await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow( + await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow( "User symmetric key not found", ); }); @@ -381,7 +479,9 @@ describe("deviceTrustCryptoService", () => { it(`throws an error if ${method} fails`, async () => { const methodSpy = spy(); methodSpy.mockRejectedValue(new Error(errorText)); - await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(errorText); + await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow( + errorText, + ); }); test.each([null, undefined])( @@ -389,11 +489,17 @@ describe("deviceTrustCryptoService", () => { async (invalidValue) => { const methodSpy = spy(); methodSpy.mockResolvedValue(invalidValue); - await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(); + await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(); }, ); }, ); + + it("throws an error when a null user id is passed in", async () => { + await expect(deviceTrustCryptoService.trustDevice(null)).rejects.toThrow( + "UserId is required. Cannot trust device.", + ); + }); }); describe("decryptUserKeyWithDeviceKey", () => { @@ -422,19 +528,26 @@ describe("deviceTrustCryptoService", () => { jest.clearAllMocks(); }); - it("returns null when device key isn't provided and isn't in state", async () => { - const getDeviceKeySpy = jest - .spyOn(deviceTrustCryptoService, "getDeviceKey") - .mockResolvedValue(null); + it("throws an error when a null user id is passed in", async () => { + await expect( + deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + null, + mockEncryptedDevicePrivateKey, + mockEncryptedUserKey, + mockDeviceKey, + ), + ).rejects.toThrow("UserId is required. Cannot decrypt user key with device key."); + }); + it("returns null when device key isn't provided", async () => { const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockUserId, mockEncryptedDevicePrivateKey, mockEncryptedUserKey, + mockDeviceKey, ); expect(result).toBeNull(); - - expect(getDeviceKeySpy).toHaveBeenCalledTimes(1); }); it("successfully returns the user key when provided keys (including device key) can decrypt it", async () => { @@ -446,6 +559,7 @@ describe("deviceTrustCryptoService", () => { .mockResolvedValue(new Uint8Array(userKeyBytesLength)); const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockUserId, mockEncryptedDevicePrivateKey, mockEncryptedUserKey, mockDeviceKey, @@ -456,31 +570,6 @@ describe("deviceTrustCryptoService", () => { expect(rsaDecryptSpy).toHaveBeenCalledTimes(1); }); - it("successfully returns the user key when a device key is not provided (retrieves device key from state)", async () => { - const getDeviceKeySpy = jest - .spyOn(deviceTrustCryptoService, "getDeviceKey") - .mockResolvedValue(mockDeviceKey); - - const decryptToBytesSpy = jest - .spyOn(encryptService, "decryptToBytes") - .mockResolvedValue(new Uint8Array(userKeyBytesLength)); - const rsaDecryptSpy = jest - .spyOn(cryptoService, "rsaDecrypt") - .mockResolvedValue(new Uint8Array(userKeyBytesLength)); - - // Call without providing a device key - const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( - mockEncryptedDevicePrivateKey, - mockEncryptedUserKey, - ); - - expect(getDeviceKeySpy).toHaveBeenCalledTimes(1); - - expect(result).toEqual(mockUserKey); - expect(decryptToBytesSpy).toHaveBeenCalledTimes(1); - expect(rsaDecryptSpy).toHaveBeenCalledTimes(1); - }); - it("returns null and removes device key when the decryption fails", async () => { const decryptToBytesSpy = jest .spyOn(encryptService, "decryptToBytes") @@ -488,6 +577,7 @@ describe("deviceTrustCryptoService", () => { const setDeviceKeySpy = jest.spyOn(deviceTrustCryptoService as any, "setDeviceKey"); const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockUserId, mockEncryptedDevicePrivateKey, mockEncryptedUserKey, mockDeviceKey, @@ -496,7 +586,7 @@ describe("deviceTrustCryptoService", () => { expect(result).toBeNull(); expect(decryptToBytesSpy).toHaveBeenCalledTimes(1); expect(setDeviceKeySpy).toHaveBeenCalledTimes(1); - expect(setDeviceKeySpy).toHaveBeenCalledWith(null); + expect(setDeviceKeySpy).toHaveBeenCalledWith(mockUserId, null); }); }); @@ -514,19 +604,28 @@ describe("deviceTrustCryptoService", () => { cryptoService.activeUserKey$ = of(fakeNewUserKey); }); - it("does an early exit when the current device is not a trusted device", async () => { - stateService.getDeviceKey.mockResolvedValue(null); + it("throws an error when a null user id is passed in", async () => { + await expect( + deviceTrustCryptoService.rotateDevicesTrust(null, fakeNewUserKey, ""), + ).rejects.toThrow("UserId is required. Cannot rotate device's trust."); + }); - await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, ""); + it("does an early exit when the current device is not a trusted device", async () => { + const deviceKeyState: FakeActiveUserState = + stateProvider.activeUser.getFake(DEVICE_KEY); + deviceKeyState.nextState(null); + + await deviceTrustCryptoService.rotateDevicesTrust(mockUserId, fakeNewUserKey, ""); expect(devicesApiService.updateTrust).not.toHaveBeenCalled(); }); describe("is on a trusted device", () => { - beforeEach(() => { - stateService.getDeviceKey.mockResolvedValue( - new SymmetricCryptoKey(new Uint8Array(deviceKeyBytesLength)) as DeviceKey, - ); + beforeEach(async () => { + const mockDeviceKey = new SymmetricCryptoKey( + new Uint8Array(deviceKeyBytesLength), + ) as DeviceKey; + await stateProvider.setUserState(DEVICE_KEY, mockDeviceKey, mockUserId); }); it("rotates current device keys and calls api service when the current device is trusted", async () => { @@ -592,7 +691,11 @@ describe("deviceTrustCryptoService", () => { ); }); - await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, "my_password_hash"); + await deviceTrustCryptoService.rotateDevicesTrust( + mockUserId, + fakeNewUserKey, + "my_password_hash", + ); expect(devicesApiService.updateTrust).toHaveBeenCalledWith( matches((updateTrustModel: UpdateDevicesTrustRequest) => { @@ -608,4 +711,32 @@ describe("deviceTrustCryptoService", () => { }); }); }); + + // Helpers + function createDeviceTrustCryptoService( + mockUserId: UserId | null, + supportsSecureStorage: boolean, + ) { + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + + platformUtilsService.supportsSecureStorage.mockReturnValue(supportsSecureStorage); + + decryptionOptions.next({} as any); + userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions; + + return new DeviceTrustCryptoService( + keyGenerationService, + cryptoFunctionService, + cryptoService, + encryptService, + appIdService, + devicesApiService, + i18nService, + platformUtilsService, + stateProvider, + secureStorageService, + userDecryptionOptionsService, + ); + } }); diff --git a/libs/common/src/auth/services/key-connector.service.spec.ts b/libs/common/src/auth/services/key-connector.service.spec.ts new file mode 100644 index 00000000000..50fed856f97 --- /dev/null +++ b/libs/common/src/auth/services/key-connector.service.spec.ts @@ -0,0 +1,376 @@ +import { mock } from "jest-mock-extended"; + +import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec"; +import { ApiService } from "../../abstractions/api.service"; +import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationData } from "../../admin-console/models/data/organization.data"; +import { Organization } from "../../admin-console/models/domain/organization"; +import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { Utils } from "../../platform/misc/utils"; +import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { KeyGenerationService } from "../../platform/services/key-generation.service"; +import { OrganizationId, UserId } from "../../types/guid"; +import { MasterKey } from "../../types/key"; +import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user-key.request"; +import { KeyConnectorUserKeyResponse } from "../models/response/key-connector-user-key.response"; + +import { + USES_KEY_CONNECTOR, + CONVERT_ACCOUNT_TO_KEY_CONNECTOR, + KeyConnectorService, +} from "./key-connector.service"; +import { TokenService } from "./token.service"; + +describe("KeyConnectorService", () => { + let keyConnectorService: KeyConnectorService; + + const cryptoService = mock(); + const apiService = mock(); + const tokenService = mock(); + const logService = mock(); + const organizationService = mock(); + const keyGenerationService = mock(); + + let stateProvider: FakeStateProvider; + + let accountService: FakeAccountService; + + const mockUserId = Utils.newGuid() as UserId; + const mockOrgId = Utils.newGuid() as OrganizationId; + + const mockMasterKeyResponse: KeyConnectorUserKeyResponse = new KeyConnectorUserKeyResponse({ + key: "eO9nVlVl3I3sU6O+CyK0kEkpGtl/auT84Hig2WTXmZtDTqYtKpDvUPfjhgMOHf+KQzx++TVS2AOLYq856Caa7w==", + }); + + beforeEach(() => { + jest.clearAllMocks(); + + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + + keyConnectorService = new KeyConnectorService( + cryptoService, + apiService, + tokenService, + logService, + organizationService, + keyGenerationService, + async () => {}, + stateProvider, + ); + }); + + it("instantiates", () => { + expect(keyConnectorService).not.toBeFalsy(); + }); + + describe("setUsesKeyConnector()", () => { + it("should update the usesKeyConnectorState with the provided value", async () => { + const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR); + state.nextState(false); + + const newValue = true; + + await keyConnectorService.setUsesKeyConnector(newValue); + + expect(await keyConnectorService.getUsesKeyConnector()).toBe(newValue); + }); + }); + + describe("getManagingOrganization()", () => { + it("should return the managing organization with key connector enabled", async () => { + // Arrange + const orgs = [ + organizationData(true, true, "https://key-connector-url.com", 2, false), + organizationData(false, true, "https://key-connector-url.com", 2, false), + organizationData(true, false, "https://key-connector-url.com", 2, false), + organizationData(true, true, "https://other-url.com", 2, false), + ]; + organizationService.getAll.mockResolvedValue(orgs); + + // Act + const result = await keyConnectorService.getManagingOrganization(); + + // Assert + expect(result).toEqual(orgs[0]); + }); + + it("should return undefined if no managing organization with key connector enabled is found", async () => { + // Arrange + const orgs = [ + organizationData(true, false, "https://key-connector-url.com", 2, false), + organizationData(false, false, "https://key-connector-url.com", 2, false), + ]; + organizationService.getAll.mockResolvedValue(orgs); + + // Act + const result = await keyConnectorService.getManagingOrganization(); + + // Assert + expect(result).toBeUndefined(); + }); + + it("should return undefined if user is Owner or Admin", async () => { + // Arrange + const orgs = [ + organizationData(true, true, "https://key-connector-url.com", 0, false), + organizationData(true, true, "https://key-connector-url.com", 1, false), + ]; + organizationService.getAll.mockResolvedValue(orgs); + + // Act + const result = await keyConnectorService.getManagingOrganization(); + + // Assert + expect(result).toBeUndefined(); + }); + + it("should return undefined if user is a Provider", async () => { + // Arrange + const orgs = [ + organizationData(true, true, "https://key-connector-url.com", 2, true), + organizationData(false, true, "https://key-connector-url.com", 2, true), + ]; + organizationService.getAll.mockResolvedValue(orgs); + + // Act + const result = await keyConnectorService.getManagingOrganization(); + + // Assert + expect(result).toBeUndefined(); + }); + }); + + describe("setConvertAccountRequired()", () => { + it("should update the convertAccountToKeyConnectorState with the provided value", async () => { + const state = stateProvider.activeUser.getFake(CONVERT_ACCOUNT_TO_KEY_CONNECTOR); + state.nextState(false); + + const newValue = true; + + await keyConnectorService.setConvertAccountRequired(newValue); + + expect(await keyConnectorService.getConvertAccountRequired()).toBe(newValue); + }); + + it("should remove the convertAccountToKeyConnectorState", async () => { + const state = stateProvider.activeUser.getFake(CONVERT_ACCOUNT_TO_KEY_CONNECTOR); + state.nextState(false); + + const newValue: boolean = null; + + await keyConnectorService.setConvertAccountRequired(newValue); + + expect(await keyConnectorService.getConvertAccountRequired()).toBe(newValue); + }); + }); + + describe("userNeedsMigration()", () => { + it("should return true if the user needs migration", async () => { + // token + tokenService.getIsExternal.mockResolvedValue(true); + + // create organization object + const data = organizationData(true, true, "https://key-connector-url.com", 2, false); + organizationService.getAll.mockResolvedValue([data]); + + // uses KeyConnector + const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR); + state.nextState(false); + + const result = await keyConnectorService.userNeedsMigration(); + + expect(result).toBe(true); + }); + + it("should return false if the user does not need migration", async () => { + tokenService.getIsExternal.mockResolvedValue(false); + const data = organizationData(false, false, "https://key-connector-url.com", 2, false); + organizationService.getAll.mockResolvedValue([data]); + + const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR); + state.nextState(true); + const result = await keyConnectorService.userNeedsMigration(); + + expect(result).toBe(false); + }); + }); + + describe("setMasterKeyFromUrl", () => { + it("should set the master key from the provided URL", async () => { + // Arrange + const url = "https://key-connector-url.com"; + + apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse); + + // Hard to mock these, but we can generate the same keys + const keyArr = Utils.fromB64ToArray(mockMasterKeyResponse.key); + const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey; + + // Act + await keyConnectorService.setMasterKeyFromUrl(url); + + // Assert + expect(apiService.getMasterKeyFromKeyConnector).toHaveBeenCalledWith(url); + expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey); + }); + + it("should handle errors thrown during the process", async () => { + // Arrange + const url = "https://key-connector-url.com"; + + const error = new Error("Failed to get master key"); + apiService.getMasterKeyFromKeyConnector.mockRejectedValue(error); + jest.spyOn(logService, "error"); + + try { + // Act + await keyConnectorService.setMasterKeyFromUrl(url); + } catch { + // Assert + expect(logService.error).toHaveBeenCalledWith(error); + expect(apiService.getMasterKeyFromKeyConnector).toHaveBeenCalledWith(url); + } + }); + }); + + describe("migrateUser()", () => { + it("should migrate the user to the key connector", async () => { + // Arrange + const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); + const masterKey = getMockMasterKey(); + const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); + + jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); + jest.spyOn(cryptoService, "getMasterKey").mockResolvedValue(masterKey); + jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue(); + + // Act + await keyConnectorService.migrateUser(); + + // Assert + expect(keyConnectorService.getManagingOrganization).toHaveBeenCalled(); + expect(cryptoService.getMasterKey).toHaveBeenCalled(); + expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( + organization.keyConnectorUrl, + keyConnectorRequest, + ); + expect(apiService.postConvertToKeyConnector).toHaveBeenCalled(); + }); + + it("should handle errors thrown during migration", async () => { + // Arrange + const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); + const masterKey = getMockMasterKey(); + const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); + const error = new Error("Failed to post user key to key connector"); + organizationService.getAll.mockResolvedValue([organization]); + + jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); + jest.spyOn(cryptoService, "getMasterKey").mockResolvedValue(masterKey); + jest.spyOn(apiService, "postUserKeyToKeyConnector").mockRejectedValue(error); + jest.spyOn(logService, "error"); + + try { + // Act + await keyConnectorService.migrateUser(); + } catch { + // Assert + expect(logService.error).toHaveBeenCalledWith(error); + expect(keyConnectorService.getManagingOrganization).toHaveBeenCalled(); + expect(cryptoService.getMasterKey).toHaveBeenCalled(); + expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( + organization.keyConnectorUrl, + keyConnectorRequest, + ); + } + }); + }); + + function organizationData( + usesKeyConnector: boolean, + keyConnectorEnabled: boolean, + keyConnectorUrl: string, + userType: number, + isProviderUser: boolean, + ): Organization { + return new Organization( + new OrganizationData( + new ProfileOrganizationResponse({ + id: mockOrgId, + name: "TEST_KEY_CONNECTOR_ORG", + usePolicies: true, + useSso: true, + useKeyConnector: usesKeyConnector, + useScim: true, + useGroups: true, + useDirectory: true, + useEvents: true, + useTotp: true, + use2fa: true, + useApi: true, + useResetPassword: true, + useSecretsManager: true, + usePasswordManager: true, + usersGetPremium: true, + useCustomPermissions: true, + useActivateAutofillPolicy: true, + selfHost: true, + seats: 5, + maxCollections: null, + maxStorageGb: 1, + key: "super-secret-key", + status: 2, + type: userType, + enabled: true, + ssoBound: true, + identifier: "TEST_KEY_CONNECTOR_ORG", + permissions: { + accessEventLogs: false, + accessImportExport: false, + accessReports: false, + createNewCollections: false, + editAnyCollection: false, + deleteAnyCollection: false, + editAssignedCollections: false, + deleteAssignedCollections: false, + manageGroups: false, + managePolicies: false, + manageSso: false, + manageUsers: false, + manageResetPassword: false, + manageScim: false, + }, + resetPasswordEnrolled: true, + userId: mockUserId, + hasPublicAndPrivateKeys: true, + providerId: null, + providerName: null, + providerType: null, + familySponsorshipFriendlyName: null, + familySponsorshipAvailable: true, + planProductType: 3, + KeyConnectorEnabled: keyConnectorEnabled, + KeyConnectorUrl: keyConnectorUrl, + familySponsorshipLastSyncDate: null, + familySponsorshipValidUntil: null, + familySponsorshipToDelete: null, + accessSecretsManager: false, + limitCollectionCreationDeletion: true, + allowAdminAccessToAllCollectionItems: true, + flexibleCollections: false, + object: "profileOrganization", + }), + { isMember: true, isProviderUser: isProviderUser }, + ), + ); + } + + function getMockMasterKey(): MasterKey { + const keyArr = Utils.fromB64ToArray(mockMasterKeyResponse.key); + const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey; + return masterKey; + } +}); diff --git a/libs/common/src/auth/services/key-connector.service.ts b/libs/common/src/auth/services/key-connector.service.ts index cded13a74bf..d1502ce06c3 100644 --- a/libs/common/src/auth/services/key-connector.service.ts +++ b/libs/common/src/auth/services/key-connector.service.ts @@ -1,3 +1,5 @@ +import { firstValueFrom } from "rxjs"; + import { ApiService } from "../../abstractions/api.service"; import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserType } from "../../admin-console/enums"; @@ -5,9 +7,14 @@ import { KeysRequest } from "../../models/request/keys.request"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; import { LogService } from "../../platform/abstractions/log.service"; -import { StateService } from "../../platform/abstractions/state.service"; import { Utils } from "../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { + ActiveUserState, + KEY_CONNECTOR_DISK, + StateProvider, + UserKeyDefinition, +} from "../../platform/state"; import { MasterKey } from "../../types/key"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service"; import { TokenService } from "../abstractions/token.service"; @@ -16,9 +23,28 @@ import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user import { SetKeyConnectorKeyRequest } from "../models/request/set-key-connector-key.request"; import { IdentityTokenResponse } from "../models/response/identity-token.response"; +export const USES_KEY_CONNECTOR = new UserKeyDefinition( + KEY_CONNECTOR_DISK, + "usesKeyConnector", + { + deserializer: (usesKeyConnector) => usesKeyConnector, + clearOn: ["logout"], + }, +); + +export const CONVERT_ACCOUNT_TO_KEY_CONNECTOR = new UserKeyDefinition( + KEY_CONNECTOR_DISK, + "convertAccountToKeyConnector", + { + deserializer: (convertAccountToKeyConnector) => convertAccountToKeyConnector, + clearOn: ["logout"], + }, +); + export class KeyConnectorService implements KeyConnectorServiceAbstraction { + private usesKeyConnectorState: ActiveUserState; + private convertAccountToKeyConnectorState: ActiveUserState; constructor( - private stateService: StateService, private cryptoService: CryptoService, private apiService: ApiService, private tokenService: TokenService, @@ -26,14 +52,20 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { private organizationService: OrganizationService, private keyGenerationService: KeyGenerationService, private logoutCallback: (expired: boolean, userId?: string) => Promise, - ) {} - - setUsesKeyConnector(usesKeyConnector: boolean) { - return this.stateService.setUsesKeyConnector(usesKeyConnector); + private stateProvider: StateProvider, + ) { + this.usesKeyConnectorState = this.stateProvider.getActive(USES_KEY_CONNECTOR); + this.convertAccountToKeyConnectorState = this.stateProvider.getActive( + CONVERT_ACCOUNT_TO_KEY_CONNECTOR, + ); } - async getUsesKeyConnector(): Promise { - return await this.stateService.getUsesKeyConnector(); + async setUsesKeyConnector(usesKeyConnector: boolean) { + await this.usesKeyConnectorState.update(() => usesKeyConnector); + } + + getUsesKeyConnector(): Promise { + return firstValueFrom(this.usesKeyConnectorState.state$); } async userNeedsMigration() { @@ -132,19 +164,15 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { } async setConvertAccountRequired(status: boolean) { - await this.stateService.setConvertAccountToKeyConnector(status); + await this.convertAccountToKeyConnectorState.update(() => status); } - async getConvertAccountRequired(): Promise { - return await this.stateService.getConvertAccountToKeyConnector(); + getConvertAccountRequired(): Promise { + return firstValueFrom(this.convertAccountToKeyConnectorState.state$); } async removeConvertAccountRequired() { - await this.stateService.setConvertAccountToKeyConnector(null); - } - - async clear() { - await this.removeConvertAccountRequired(); + await this.setConvertAccountRequired(null); } private handleKeyConnectorError(e: any) { diff --git a/libs/common/src/auth/services/login.service.ts b/libs/common/src/auth/services/login.service.ts deleted file mode 100644 index f1d038b2f80..00000000000 --- a/libs/common/src/auth/services/login.service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { StateService } from "../../platform/abstractions/state.service"; -import { LoginService as LoginServiceAbstraction } from "../abstractions/login.service"; - -export class LoginService implements LoginServiceAbstraction { - private _email: string; - private _rememberEmail: boolean; - - constructor(private stateService: StateService) {} - - getEmail() { - return this._email; - } - - getRememberEmail() { - return this._rememberEmail; - } - - setEmail(value: string) { - this._email = value; - } - - setRememberEmail(value: boolean) { - this._rememberEmail = value; - } - - clearValues() { - this._email = null; - this._rememberEmail = null; - } - - async saveEmailSettings() { - await this.stateService.setRememberedEmail(this._rememberEmail ? this._email : null); - this.clearValues(); - } -} diff --git a/libs/common/src/auth/services/token.service.spec.ts b/libs/common/src/auth/services/token.service.spec.ts index a7b953f9280..c4092632099 100644 --- a/libs/common/src/auth/services/token.service.spec.ts +++ b/libs/common/src/auth/services/token.service.spec.ts @@ -1,7 +1,11 @@ -import { mock } from "jest-mock-extended"; +import { MockProxy, mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; +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"; @@ -12,7 +16,6 @@ import { DecodedAccessToken, TokenService } from "./token.service"; import { ACCESS_TOKEN_DISK, ACCESS_TOKEN_MEMORY, - ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, API_KEY_CLIENT_ID_DISK, API_KEY_CLIENT_ID_MEMORY, API_KEY_CLIENT_SECRET_DISK, @@ -28,7 +31,10 @@ describe("TokenService", () => { let singleUserStateProvider: FakeSingleUserStateProvider; let globalStateProvider: FakeGlobalStateProvider; - const secureStorageService = mock(); + let secureStorageService: MockProxy; + let keyGenerationService: MockProxy; + let encryptService: MockProxy; + let logService: MockProxy; const memoryVaultTimeoutAction = VaultTimeoutAction.LogOut; const memoryVaultTimeout = 30; @@ -74,12 +80,19 @@ describe("TokenService", () => { userId: userIdFromAccessToken, }; + const accessTokenKeyB64 = { keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" }; + beforeEach(() => { jest.clearAllMocks(); singleUserStateProvider = new FakeSingleUserStateProvider(); globalStateProvider = new FakeGlobalStateProvider(); + secureStorageService = mock(); + keyGenerationService = mock(); + encryptService = mock(); + logService = mock(); + const supportsSecureStorage = false; // default to false; tests will override as needed tokenService = createTokenService(supportsSecureStorage); }); @@ -89,8 +102,63 @@ describe("TokenService", () => { }); describe("Access Token methods", () => { - const accessTokenPartialSecureStorageKey = `_accessToken`; - const accessTokenSecureStorageKey = `${userIdFromAccessToken}${accessTokenPartialSecureStorageKey}`; + const accessTokenKeyPartialSecureStorageKey = `_accessTokenKey`; + const accessTokenKeySecureStorageKey = `${userIdFromAccessToken}${accessTokenKeyPartialSecureStorageKey}`; + + describe("hasAccessToken$", () => { + it("returns true when an access token exists in memory", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Act + const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken)); + + // Assert + expect(result).toEqual(true); + }); + + it("returns true when an access token exists in disk", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Act + const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken)); + + // Assert + expect(result).toEqual(true); + }); + + it("returns true when an access token exists in secure storage", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]); + + secureStorageService.get.mockResolvedValue(accessTokenKeyB64); + + // Act + const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken)); + + // Assert + expect(result).toEqual(true); + }); + + it("should return false if no access token exists in memory, disk, or secure storage", async () => { + // Act + const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken)); + + // Assert + expect(result).toEqual(false); + }); + }); describe("setAccessToken", () => { it("should throw an error if the access token is null", async () => { @@ -150,18 +218,22 @@ describe("TokenService", () => { tokenService = createTokenService(supportsSecureStorage); }); - it("should set the access token in secure storage, null out data on disk or in memory, and set a flag to indicate the token has been migrated", async () => { + it("should set an access token key in secure storage, the encrypted access token in disk, and clear out the token in memory", async () => { // Arrange: - // For testing purposes, let's assume that the access token is already in disk and memory - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); - + // For testing purposes, let's assume that the access token is already in memory singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + keyGenerationService.createKey.mockResolvedValue("accessTokenKey" as any); + + const mockEncryptedAccessToken = "encryptedAccessToken"; + + encryptService.encrypt.mockResolvedValue({ + encryptedString: mockEncryptedAccessToken, + } as any); + // Act await tokenService.setAccessToken( accessTokenJwt, @@ -170,27 +242,22 @@ describe("TokenService", () => { ); // Assert - // assert that the access token was set in secure storage + // assert that the AccessTokenKey was set in secure storage expect(secureStorageService.save).toHaveBeenCalledWith( - accessTokenSecureStorageKey, - accessTokenJwt, + accessTokenKeySecureStorageKey, + "accessTokenKey", secureStorageOptions, ); - // assert data was migrated out of disk and memory + flag was set + // assert that the access token was encrypted and set in disk expect( singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock, - ).toHaveBeenCalledWith(null); + ).toHaveBeenCalledWith(mockEncryptedAccessToken); + + // assert data was migrated out of memory expect( singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock, ).toHaveBeenCalledWith(null); - - expect( - singleUserStateProvider.getFake( - userIdFromAccessToken, - ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, - ).nextMock, - ).toHaveBeenCalledWith(true); }); }); }); @@ -216,7 +283,13 @@ describe("TokenService", () => { }); describe("Memory storage tests", () => { - it("should get the access token from memory with no user id specified (uses global active user)", async () => { + 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], + ])("%s", async (_, userId) => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -228,37 +301,28 @@ describe("TokenService", () => { .stateSubject.next([userIdFromAccessToken, undefined]); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + if (!userId) { + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + } // Act - const result = await tokenService.getAccessToken(); + const result = await tokenService.getAccessToken(userId); // Assert expect(result).toEqual(accessTokenJwt); }); - - it("should get the access token from memory for the specified user id", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); - - // set disk to undefined - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); - - // Act - const result = await tokenService.getAccessToken(userIdFromAccessToken); - // Assert - expect(result).toEqual(accessTokenJwt); - }); }); describe("Disk storage tests (secure storage not supported on platform)", () => { - it("should get the access token from disk with no user id specified", async () => { + 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], + ])("%s", async (_, userId) => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -269,28 +333,14 @@ describe("TokenService", () => { .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + if (!userId) { + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + } // Act - const result = await tokenService.getAccessToken(); - // Assert - expect(result).toEqual(accessTokenJwt); - }); - - it("should get the access token from disk for the specified user id", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); - - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); - - // Act - const result = await tokenService.getAccessToken(userIdFromAccessToken); + const result = await tokenService.getAccessToken(userId); // Assert expect(result).toEqual(accessTokenJwt); }); @@ -302,7 +352,16 @@ describe("TokenService", () => { tokenService = createTokenService(supportsSecureStorage); }); - it("should get the access token from secure storage when no user id is specified and the migration flag is set to true", async () => { + test.each([ + [ + "should get the encrypted access token from disk, decrypt it, and return it when user id is provided", + userIdFromAccessToken, + ], + [ + "should get the encrypted access token from disk, decrypt it, and return it when no user id is provided", + undefined, + ], + ])("%s", async (_, userId) => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -310,76 +369,35 @@ describe("TokenService", () => { singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); + .stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]); - secureStorageService.get.mockResolvedValue(accessTokenJwt); + secureStorageService.get.mockResolvedValue(accessTokenKeyB64); + encryptService.decryptToUtf8.mockResolvedValue("decryptedAccessToken"); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); - - // set access token migration flag to true - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) - .stateSubject.next([userIdFromAccessToken, true]); + if (!userId) { + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + } // Act - const result = await tokenService.getAccessToken(); - // Assert - expect(result).toEqual(accessTokenJwt); - }); - - it("should get the access token from secure storage when user id is specified and the migration flag set to true", async () => { - // Arrange - - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); - - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, undefined]); - - secureStorageService.get.mockResolvedValue(accessTokenJwt); - - // set access token migration flag to true - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) - .stateSubject.next([userIdFromAccessToken, true]); - - // Act - const result = await tokenService.getAccessToken(userIdFromAccessToken); - // Assert - expect(result).toEqual(accessTokenJwt); - }); - - it("should fallback and get the access token from disk when user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, undefined]); - - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); - - // set access token migration flag to false - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) - .stateSubject.next([userIdFromAccessToken, false]); - - // Act - const result = await tokenService.getAccessToken(userIdFromAccessToken); + const result = await tokenService.getAccessToken(userId); // Assert - expect(result).toEqual(accessTokenJwt); - - // assert that secure storage was not called - expect(secureStorageService.get).not.toHaveBeenCalled(); + expect(result).toEqual("decryptedAccessToken"); }); - it("should fallback and get the access 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 () => { + 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", + 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", + undefined, + ], + ])("%s", async (_, userId) => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -390,23 +408,19 @@ describe("TokenService", () => { .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); + if (!userId) { + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + } - // set access token migration flag to false - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) - .stateSubject.next([userIdFromAccessToken, false]); + // No access token key set // Act - const result = await tokenService.getAccessToken(); + const result = await tokenService.getAccessToken(userId); // Assert expect(result).toEqual(accessTokenJwt); - - // assert that secure storage was not called - expect(secureStorageService.get).not.toHaveBeenCalled(); }); }); }); @@ -426,7 +440,16 @@ describe("TokenService", () => { tokenService = createTokenService(supportsSecureStorage); }); - it("should clear the access token from all storage locations for the specified user id", async () => { + test.each([ + [ + "should clear the access token from all storage locations for the provided user id", + userIdFromAccessToken, + ], + [ + "should clear the access token from all storage locations for the global active user", + undefined, + ], + ])("%s", async (_, userId) => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -436,6 +459,13 @@ describe("TokenService", () => { .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + // Need to have global active id set to the user id + if (!userId) { + globalStateProvider + .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) + .stateSubject.next(userIdFromAccessToken); + } + // Act await tokenService.clearAccessToken(userIdFromAccessToken); @@ -448,39 +478,7 @@ describe("TokenService", () => { ).toHaveBeenCalledWith(null); expect(secureStorageService.remove).toHaveBeenCalledWith( - accessTokenSecureStorageKey, - secureStorageOptions, - ); - }); - - it("should clear the access token from all storage locations for the global active user", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); - - singleUserStateProvider - .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) - .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); - - // Need to have global active id set to the user id - globalStateProvider - .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) - .stateSubject.next(userIdFromAccessToken); - - // Act - await tokenService.clearAccessToken(); - - // Assert - expect( - singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock, - ).toHaveBeenCalledWith(null); - expect( - singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock, - ).toHaveBeenCalledWith(null); - - expect(secureStorageService.remove).toHaveBeenCalledWith( - accessTokenSecureStorageKey, + accessTokenKeySecureStorageKey, secureStorageOptions, ); }); @@ -1049,6 +1047,7 @@ describe("TokenService", () => { refreshToken, VaultTimeoutAction.Lock, null, + null, ); // Assert await expect(result).rejects.toThrow("User id not found. Cannot save refresh token."); @@ -1912,7 +1911,7 @@ describe("TokenService", () => { // Act // Note: passing a valid access token so that a valid user id can be determined from the access token - await tokenService.setTokens(accessTokenJwt, refreshToken, vaultTimeoutAction, vaultTimeout, [ + await tokenService.setTokens(accessTokenJwt, vaultTimeoutAction, vaultTimeout, refreshToken, [ clientId, clientSecret, ]); @@ -1959,7 +1958,7 @@ describe("TokenService", () => { tokenService.setClientSecret = jest.fn(); // Act - await tokenService.setTokens(accessTokenJwt, refreshToken, vaultTimeoutAction, vaultTimeout); + await tokenService.setTokens(accessTokenJwt, vaultTimeoutAction, vaultTimeout, refreshToken); // Assert expect((tokenService as any)._setAccessToken).toHaveBeenCalledWith( @@ -1991,9 +1990,9 @@ describe("TokenService", () => { // Act const result = tokenService.setTokens( accessToken, - refreshToken, vaultTimeoutAction, vaultTimeout, + refreshToken, ); // Assert @@ -2010,32 +2009,27 @@ describe("TokenService", () => { // Act const result = tokenService.setTokens( accessToken, - refreshToken, vaultTimeoutAction, vaultTimeout, + refreshToken, ); // Assert - await expect(result).rejects.toThrow("Access token and refresh token are required."); + await expect(result).rejects.toThrow("Access token is required."); }); - it("should throw an error if the refresh token is missing", async () => { + it("should not throw an error if the refresh token is missing and it should just not set it", async () => { // Arrange - const accessToken = "accessToken"; const refreshToken: string = null; const vaultTimeoutAction = VaultTimeoutAction.Lock; const vaultTimeout = 30; + (tokenService as any).setRefreshToken = jest.fn(); // Act - const result = tokenService.setTokens( - accessToken, - refreshToken, - vaultTimeoutAction, - vaultTimeout, - ); + await tokenService.setTokens(accessTokenJwt, vaultTimeoutAction, vaultTimeout, refreshToken); // Assert - await expect(result).rejects.toThrow("Access token and refresh token are required."); + expect((tokenService as any).setRefreshToken).not.toHaveBeenCalled(); }); }); @@ -2232,6 +2226,9 @@ describe("TokenService", () => { globalStateProvider, supportsSecureStorage, secureStorageService, + keyGenerationService, + encryptService, + logService, ); } }); diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index 4e9722614ed..fb13c218705 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -1,11 +1,17 @@ -import { firstValueFrom } from "rxjs"; +import { Observable, combineLatest, firstValueFrom, map } from "rxjs"; +import { Opaque } from "type-fest"; import { decodeJwtTokenToJson } from "@bitwarden/auth/common"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; +import { LogService } from "../../platform/abstractions/log.service"; import { AbstractStorageService } from "../../platform/abstractions/storage.service"; import { StorageLocation } from "../../platform/enums"; +import { EncString, EncryptedString } from "../../platform/models/domain/enc-string"; import { StorageOptions } from "../../platform/models/domain/storage-options"; +import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { GlobalState, GlobalStateProvider, @@ -19,7 +25,6 @@ import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service"; import { ACCESS_TOKEN_DISK, ACCESS_TOKEN_MEMORY, - ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, API_KEY_CLIENT_ID_DISK, API_KEY_CLIENT_ID_MEMORY, API_KEY_CLIENT_SECRET_DISK, @@ -101,8 +106,14 @@ export type DecodedAccessToken = { jti?: string; }; +/** + * 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 class TokenService implements TokenServiceAbstraction { - private readonly accessTokenSecureStorageKey: string = "_accessToken"; + private readonly accessTokenKeySecureStorageKey: string = "_accessTokenKey"; private readonly refreshTokenSecureStorageKey: string = "_refreshToken"; @@ -117,10 +128,26 @@ export class TokenService implements TokenServiceAbstraction { private globalStateProvider: GlobalStateProvider, private readonly platformSupportsSecureStorage: boolean, private secureStorageService: AbstractStorageService, + private keyGenerationService: KeyGenerationService, + private encryptService: EncryptService, + private logService: LogService, ) { this.initializeState(); } + hasAccessToken$(userId: UserId): Observable { + // FIXME Once once vault timeout action is observable, we can use it to determine storage location + // and avoid the need to check both disk and memory. + return combineLatest([ + this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).state$, + this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).state$, + ]).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, @@ -131,13 +158,13 @@ export class TokenService implements TokenServiceAbstraction { async setTokens( accessToken: string, - refreshToken: string, vaultTimeoutAction: VaultTimeoutAction, vaultTimeout: number | null, + refreshToken?: string, clientIdClientSecret?: [string, string], ): Promise { - if (!accessToken || !refreshToken) { - throw new Error("Access token and refresh token are required."); + if (!accessToken) { + throw new Error("Access token is required."); } // get user id the access token @@ -148,13 +175,95 @@ export class TokenService implements TokenServiceAbstraction { } await this._setAccessToken(accessToken, vaultTimeoutAction, vaultTimeout, userId); - await this.setRefreshToken(refreshToken, vaultTimeoutAction, vaultTimeout, userId); + + if (refreshToken) { + await this.setRefreshToken(refreshToken, vaultTimeoutAction, vaultTimeout, userId); + } + if (clientIdClientSecret != null) { await this.setClientId(clientIdClientSecret[0], vaultTimeoutAction, vaultTimeout, userId); await this.setClientSecret(clientIdClientSecret[1], vaultTimeoutAction, vaultTimeout, userId); } } + private async getAccessTokenKey(userId: UserId): Promise { + const accessTokenKeyB64 = await this.secureStorageService.get< + ReturnType + >(`${userId}${this.accessTokenKeySecureStorageKey}`, this.getSecureStorageOptions(userId)); + + if (!accessTokenKeyB64) { + return null; + } + + const accessTokenKey = SymmetricCryptoKey.fromJSON(accessTokenKeyB64) as AccessTokenKey; + return accessTokenKey; + } + + private async createAndSaveAccessTokenKey(userId: UserId): Promise { + const newAccessTokenKey = (await this.keyGenerationService.createKey(512)) as AccessTokenKey; + + await this.secureStorageService.save( + `${userId}${this.accessTokenKeySecureStorageKey}`, + newAccessTokenKey, + this.getSecureStorageOptions(userId), + ); + + return newAccessTokenKey; + } + + private async clearAccessTokenKey(userId: UserId): Promise { + await this.secureStorageService.remove( + `${userId}${this.accessTokenKeySecureStorageKey}`, + this.getSecureStorageOptions(userId), + ); + } + + private async getOrCreateAccessTokenKey(userId: UserId): Promise { + if (!this.platformSupportsSecureStorage) { + throw new Error("Platform does not support secure storage. Cannot obtain access token key."); + } + + if (!userId) { + throw new Error("User id not found. Cannot obtain access token key."); + } + + // First see if we have an accessTokenKey in secure storage and return it if we do + let accessTokenKey: AccessTokenKey = await this.getAccessTokenKey(userId); + + if (!accessTokenKey) { + // Otherwise, create a new one and save it to secure storage, then return it + accessTokenKey = await this.createAndSaveAccessTokenKey(userId); + } + + return accessTokenKey; + } + + private async encryptAccessToken(accessToken: string, userId: UserId): Promise { + const accessTokenKey = await this.getOrCreateAccessTokenKey(userId); + + return await this.encryptService.encrypt(accessToken, accessTokenKey); + } + + private async decryptAccessToken( + 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 the user isn't logged in. + return null; + } + + const decryptedAccessToken = await this.encryptService.decryptToUtf8( + encryptedAccessToken, + accessTokenKey, + ); + + return decryptedAccessToken; + } + /** * Internal helper for set access token which always requires user id. * This is useful because setTokens always will have a user id from the access token whereas @@ -173,26 +282,33 @@ export class TokenService implements TokenServiceAbstraction { ); switch (storageLocation) { - case TokenStorageLocation.SecureStorage: - await this.saveStringToSecureStorage(userId, this.accessTokenSecureStorageKey, accessToken); + case TokenStorageLocation.SecureStorage: { + // Secure storage implementations have variable length limitations (Windows), so we cannot + // store the access token directly. Instead, we encrypt with accessTokenKey and store that + // in secure storage. + + 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); // TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408 - // 2024-02-20: Remove access token from memory and disk so that we migrate to secure storage over time. - // Remove these 2 calls to remove the access token from memory and disk after 3 releases. - - await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null); + // 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); - // Set flag to indicate that the access token has been migrated to secure storage (don't remove this) - await this.setAccessTokenMigratedToSecureStorage(userId); - return; + } case TokenStorageLocation.Disk: + // Access token stored on disk unencrypted as platform does not support secure storage await this.singleUserStateProvider .get(userId, ACCESS_TOKEN_DISK) .update((_) => accessToken); return; case TokenStorageLocation.Memory: + // Access token stored in memory due to vault timeout settings await this.singleUserStateProvider .get(userId, ACCESS_TOKEN_MEMORY) .update((_) => accessToken); @@ -226,15 +342,14 @@ export class TokenService implements TokenServiceAbstraction { throw new Error("User id not found. Cannot clear access token."); } - // TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data. + // TODO: re-eval this implementation once we get shared key definitions for vault timeout and vault timeout action data. // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout - // but we can simply clear all locations to avoid the need to require those parameters + // but we can simply clear all locations to avoid the need to require those parameters. if (this.platformSupportsSecureStorage) { - await this.secureStorageService.remove( - `${userId}${this.accessTokenSecureStorageKey}`, - this.getSecureStorageOptions(userId), - ); + // Always clear the access token key when clearing the access token + // The next set of the access token will create a new access token key + await this.clearAccessTokenKey(userId); } // Platform doesn't support secure storage, so use state provider implementation @@ -249,36 +364,48 @@ export class TokenService implements TokenServiceAbstraction { return undefined; } - const accessTokenMigratedToSecureStorage = - await this.getAccessTokenMigratedToSecureStorage(userId); - if (this.platformSupportsSecureStorage && accessTokenMigratedToSecureStorage) { - return await this.getStringFromSecureStorage(userId, this.accessTokenSecureStorageKey); - } - // Try to get the access token from memory const accessTokenMemory = await this.getStateValueByUserIdAndKeyDef( userId, ACCESS_TOKEN_MEMORY, ); - if (accessTokenMemory != null) { return accessTokenMemory; } // If memory is null, read from disk - return await this.getStateValueByUserIdAndKeyDef(userId, ACCESS_TOKEN_DISK); - } + const accessTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, ACCESS_TOKEN_DISK); + if (!accessTokenDisk) { + return null; + } - private async getAccessTokenMigratedToSecureStorage(userId: UserId): Promise { - return await firstValueFrom( - this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE).state$, - ); - } + if (this.platformSupportsSecureStorage) { + const accessTokenKey = await this.getAccessTokenKey(userId); - private async setAccessTokenMigratedToSecureStorage(userId: UserId): Promise { - await this.singleUserStateProvider - .get(userId, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE) - .update((_) => true); + if (!accessTokenKey) { + // We know this is an unencrypted access token because we don't have an access token key + return accessTokenDisk; + } + + try { + const encryptedAccessTokenEncString = new EncString(accessTokenDisk as EncryptedString); + + const decryptedAccessToken = await this.decryptAccessToken( + encryptedAccessTokenEncString, + userId, + ); + return decryptedAccessToken; + } catch (error) { + // If an error occurs during decryption, return null for logout. + // 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."}`, + ); + return null; + } + } + return accessTokenDisk; } // Private because we only ever set the refresh token when also setting the access token @@ -417,7 +544,7 @@ export class TokenService implements TokenServiceAbstraction { const storageLocation = await this.determineStorageLocation( vaultTimeoutAction, vaultTimeout, - false, + false, // don't use secure storage for client id ); if (storageLocation === TokenStorageLocation.Disk) { @@ -484,7 +611,7 @@ export class TokenService implements TokenServiceAbstraction { const storageLocation = await this.determineStorageLocation( vaultTimeoutAction, vaultTimeout, - false, + false, // don't use secure storage for client secret ); if (storageLocation === TokenStorageLocation.Disk) { @@ -567,6 +694,7 @@ export class TokenService implements TokenServiceAbstraction { }); } + // TODO: stop accepting optional userIds async clearTokens(userId?: UserId): Promise { userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); diff --git a/libs/common/src/auth/services/token.state.spec.ts b/libs/common/src/auth/services/token.state.spec.ts index f4089a73fb4..24eddc73f56 100644 --- a/libs/common/src/auth/services/token.state.spec.ts +++ b/libs/common/src/auth/services/token.state.spec.ts @@ -3,7 +3,6 @@ import { KeyDefinition } from "../../platform/state"; import { ACCESS_TOKEN_DISK, ACCESS_TOKEN_MEMORY, - ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, API_KEY_CLIENT_ID_DISK, API_KEY_CLIENT_ID_MEMORY, API_KEY_CLIENT_SECRET_DISK, @@ -17,7 +16,6 @@ import { describe.each([ [ACCESS_TOKEN_DISK, "accessTokenDisk"], [ACCESS_TOKEN_MEMORY, "accessTokenMemory"], - [ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, true], [REFRESH_TOKEN_DISK, "refreshTokenDisk"], [REFRESH_TOKEN_MEMORY, "refreshTokenMemory"], [REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, true], diff --git a/libs/common/src/auth/services/token.state.ts b/libs/common/src/auth/services/token.state.ts index 022f56f7aa5..368f3c4ca29 100644 --- a/libs/common/src/auth/services/token.state.ts +++ b/libs/common/src/auth/services/token.state.ts @@ -1,5 +1,9 @@ import { KeyDefinition, TOKEN_DISK, TOKEN_DISK_LOCAL, TOKEN_MEMORY } from "../../platform/state"; +// Note: all tokens / API key information must be cleared on logout. +// because we are using secure storage, we must manually call to clean up our tokens. +// See stateService.deAuthenticateAccount for where we call clearTokens(...) + export const ACCESS_TOKEN_DISK = new KeyDefinition(TOKEN_DISK, "accessToken", { deserializer: (accessToken) => accessToken, }); @@ -8,14 +12,6 @@ export const ACCESS_TOKEN_MEMORY = new KeyDefinition(TOKEN_MEMORY, "acce deserializer: (accessToken) => accessToken, }); -export const ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE = new KeyDefinition( - TOKEN_DISK, - "accessTokenMigratedToSecureStorage", - { - deserializer: (accessTokenMigratedToSecureStorage) => accessTokenMigratedToSecureStorage, - }, -); - export const REFRESH_TOKEN_DISK = new KeyDefinition(TOKEN_DISK, "refreshToken", { deserializer: (refreshToken) => refreshToken, }); 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 03e267d9db5..0b4cd960993 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,7 +140,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti case VerificationType.MasterPassword: return this.verifyUserByMasterPassword(verification); case VerificationType.PIN: - break; + return this.verifyUserByPIN(verification); case VerificationType.Biometrics: return this.verifyUserByBiometrics(); default: { 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 3982fa917b6..1311976c4b1 100644 --- a/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts @@ -1,5 +1,7 @@ import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response"; +import { ProviderSubscriptionUpdateRequest } from "../models/request/provider-subscription-update.request"; +import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response"; export abstract class BillingApiServiceAbstraction { cancelOrganizationSubscription: ( @@ -8,4 +10,10 @@ export abstract class BillingApiServiceAbstraction { ) => Promise; cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise; getBillingStatus: (id: string) => Promise; + getProviderClientSubscriptions: (providerId: string) => Promise; + putProviderClientSubscriptions: ( + providerId: string, + organizationId: string, + request: ProviderSubscriptionUpdateRequest, + ) => Promise; } diff --git a/libs/common/src/billing/models/request/provider-subscription-update.request.ts b/libs/common/src/billing/models/request/provider-subscription-update.request.ts new file mode 100644 index 00000000000..f2bf4c7e971 --- /dev/null +++ b/libs/common/src/billing/models/request/provider-subscription-update.request.ts @@ -0,0 +1,3 @@ +export class ProviderSubscriptionUpdateRequest { + assignedSeats: number; +} diff --git a/libs/common/src/billing/models/response/provider-subscription-response.ts b/libs/common/src/billing/models/response/provider-subscription-response.ts new file mode 100644 index 00000000000..522c5187254 --- /dev/null +++ b/libs/common/src/billing/models/response/provider-subscription-response.ts @@ -0,0 +1,38 @@ +import { BaseResponse } from "../../../models/response/base.response"; + +export class ProviderSubscriptionResponse extends BaseResponse { + status: string; + currentPeriodEndDate: Date; + discountPercentage?: number | null; + plans: Plans[] = []; + + constructor(response: any) { + super(response); + this.status = this.getResponseProperty("status"); + this.currentPeriodEndDate = new Date(this.getResponseProperty("currentPeriodEndDate")); + this.discountPercentage = this.getResponseProperty("discountPercentage"); + const plans = this.getResponseProperty("plans"); + if (plans != null) { + this.plans = plans.map((i: any) => new Plans(i)); + } + } +} + +export class Plans extends BaseResponse { + planName: string; + seatMinimum: number; + assignedSeats: number; + purchasedSeats: number; + cost: number; + cadence: string; + + constructor(response: any) { + super(response); + this.planName = this.getResponseProperty("PlanName"); + this.seatMinimum = this.getResponseProperty("SeatMinimum"); + this.assignedSeats = this.getResponseProperty("AssignedSeats"); + this.purchasedSeats = this.getResponseProperty("PurchasedSeats"); + this.cost = this.getResponseProperty("Cost"); + this.cadence = this.getResponseProperty("Cadence"); + } +} diff --git a/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts b/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts index 4a2a94e9c60..7f0f218a239 100644 --- a/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts +++ b/libs/common/src/billing/services/account/billing-account-profile-state.service.spec.ts @@ -2,10 +2,10 @@ import { firstValueFrom } from "rxjs"; import { FakeAccountService, - FakeActiveUserStateProvider, mockAccountServiceWith, FakeActiveUserState, - trackEmissions, + FakeStateProvider, + FakeSingleUserState, } from "../../../../spec"; import { UserId } from "../../../types/guid"; import { BillingAccountProfile } from "../../abstractions/account/billing-account-profile-state.service"; @@ -16,20 +16,26 @@ import { } from "./billing-account-profile-state.service"; describe("BillingAccountProfileStateService", () => { - let activeUserStateProvider: FakeActiveUserStateProvider; + let stateProvider: FakeStateProvider; let sut: DefaultBillingAccountProfileStateService; let billingAccountProfileState: FakeActiveUserState; + let userBillingAccountProfileState: FakeSingleUserState; let accountService: FakeAccountService; const userId = "fakeUserId" as UserId; beforeEach(() => { accountService = mockAccountServiceWith(userId); - activeUserStateProvider = new FakeActiveUserStateProvider(accountService); + stateProvider = new FakeStateProvider(accountService); - sut = new DefaultBillingAccountProfileStateService(activeUserStateProvider); + sut = new DefaultBillingAccountProfileStateService(stateProvider); - billingAccountProfileState = activeUserStateProvider.getFake( + billingAccountProfileState = stateProvider.activeUser.getFake( + BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, + ); + + userBillingAccountProfileState = stateProvider.singleUser.getFake( + userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, ); }); @@ -38,9 +44,9 @@ describe("BillingAccountProfileStateService", () => { return jest.resetAllMocks(); }); - describe("accountHasPremiumFromAnyOrganization$", () => { - it("should emit changes in hasPremiumFromAnyOrganization", async () => { - billingAccountProfileState.nextState({ + describe("hasPremiumFromAnyOrganization$", () => { + it("returns true when they have premium from an organization", async () => { + userBillingAccountProfileState.nextState({ hasPremiumPersonally: false, hasPremiumFromAnyOrganization: true, }); @@ -48,118 +54,91 @@ describe("BillingAccountProfileStateService", () => { expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true); }); - it("should emit once when calling setHasPremium once", async () => { - const emissions = trackEmissions(sut.hasPremiumFromAnyOrganization$); - const startingEmissionCount = emissions.length; - - await sut.setHasPremium(true, true); - - const endingEmissionCount = emissions.length; - expect(endingEmissionCount - startingEmissionCount).toBe(1); - }); - }); - - describe("hasPremiumPersonally$", () => { - it("should emit changes in hasPremiumPersonally", async () => { - billingAccountProfileState.nextState({ - hasPremiumPersonally: true, - hasPremiumFromAnyOrganization: false, - }); - - expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true); - }); - - it("should emit once when calling setHasPremium once", async () => { - const emissions = trackEmissions(sut.hasPremiumPersonally$); - const startingEmissionCount = emissions.length; - - await sut.setHasPremium(true, true); - - const endingEmissionCount = emissions.length; - expect(endingEmissionCount - startingEmissionCount).toBe(1); - }); - }); - - describe("canAccessPremium$", () => { - it("should emit changes in hasPremiumPersonally", async () => { - billingAccountProfileState.nextState({ - hasPremiumPersonally: true, - hasPremiumFromAnyOrganization: false, - }); - - expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); - }); - - it("should emit changes in hasPremiumFromAnyOrganization", async () => { - billingAccountProfileState.nextState({ + it("return false when they do not have premium from an organization", async () => { + userBillingAccountProfileState.nextState({ hasPremiumPersonally: false, - hasPremiumFromAnyOrganization: true, + hasPremiumFromAnyOrganization: false, }); - expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); - }); - - it("should emit changes in both hasPremiumPersonally and hasPremiumFromAnyOrganization", async () => { - billingAccountProfileState.nextState({ - hasPremiumPersonally: true, - hasPremiumFromAnyOrganization: true, - }); - - expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); - }); - - it("should emit once when calling setHasPremium once", async () => { - const emissions = trackEmissions(sut.hasPremiumFromAnySource$); - const startingEmissionCount = emissions.length; - - await sut.setHasPremium(true, true); - - const endingEmissionCount = emissions.length; - expect(endingEmissionCount - startingEmissionCount).toBe(1); - }); - }); - - describe("setHasPremium", () => { - it("should have `hasPremiumPersonally$` emit `true` when passing `true` as an argument for hasPremiumPersonally", async () => { - await sut.setHasPremium(true, false); - - expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true); - }); - - it("should have `hasPremiumFromAnyOrganization$` emit `true` when passing `true` as an argument for hasPremiumFromAnyOrganization", async () => { - await sut.setHasPremium(false, true); - - expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true); - }); - - it("should have `hasPremiumPersonally$` emit `false` when passing `false` as an argument for hasPremiumPersonally", async () => { - await sut.setHasPremium(false, false); - - expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(false); - }); - - it("should have `hasPremiumFromAnyOrganization$` emit `false` when passing `false` as an argument for hasPremiumFromAnyOrganization", async () => { - await sut.setHasPremium(false, false); - expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false); }); - it("should have `canAccessPremium$` emit `true` when passing `true` as an argument for hasPremiumPersonally", async () => { - await sut.setHasPremium(true, false); + it("returns false when there is no active user", async () => { + await accountService.switchAccount(null); + + expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false); + }); + }); + + describe("hasPremiumPersonally$", () => { + it("returns true when the user has premium personally", async () => { + userBillingAccountProfileState.nextState({ + hasPremiumPersonally: true, + hasPremiumFromAnyOrganization: false, + }); + + expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true); + }); + + it("returns false when the user does not have premium personally", async () => { + userBillingAccountProfileState.nextState({ + hasPremiumPersonally: false, + hasPremiumFromAnyOrganization: false, + }); + + expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(false); + }); + + it("returns false when there is no active user", async () => { + await accountService.switchAccount(null); + + expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(false); + }); + }); + + describe("hasPremiumFromAnySource$", () => { + it("returns true when the user has premium personally", async () => { + userBillingAccountProfileState.nextState({ + hasPremiumPersonally: true, + hasPremiumFromAnyOrganization: false, + }); expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); }); - it("should have `canAccessPremium$` emit `true` when passing `true` as an argument for hasPremiumFromAnyOrganization", async () => { - await sut.setHasPremium(false, true); + it("returns true when the user has premium from an organization", async () => { + userBillingAccountProfileState.nextState({ + hasPremiumPersonally: false, + hasPremiumFromAnyOrganization: true, + }); expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); }); - it("should have `canAccessPremium$` emit `false` when passing `false` for all arguments", async () => { - await sut.setHasPremium(false, false); + it("returns true when they have premium personally AND from an organization", async () => { + userBillingAccountProfileState.nextState({ + hasPremiumPersonally: true, + hasPremiumFromAnyOrganization: true, + }); + + expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true); + }); + + it("returns false when there is no active user", async () => { + await accountService.switchAccount(null); expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(false); }); }); + + describe("setHasPremium", () => { + it("should update the active users state when called", async () => { + await sut.setHasPremium(true, false); + + expect(billingAccountProfileState.nextMock).toHaveBeenCalledWith([ + userId, + { hasPremiumPersonally: true, hasPremiumFromAnyOrganization: false }, + ]); + }); + }); }); diff --git a/libs/common/src/billing/services/account/billing-account-profile-state.service.ts b/libs/common/src/billing/services/account/billing-account-profile-state.service.ts index c6b6f104a8e..336021c9930 100644 --- a/libs/common/src/billing/services/account/billing-account-profile-state.service.ts +++ b/libs/common/src/billing/services/account/billing-account-profile-state.service.ts @@ -1,10 +1,10 @@ -import { map, Observable } from "rxjs"; +import { map, Observable, of, switchMap } from "rxjs"; import { ActiveUserState, - ActiveUserStateProvider, BILLING_DISK, KeyDefinition, + StateProvider, } from "../../../platform/state"; import { BillingAccountProfile, @@ -26,24 +26,34 @@ export class DefaultBillingAccountProfileStateService implements BillingAccountP hasPremiumPersonally$: Observable; hasPremiumFromAnySource$: Observable; - constructor(activeUserStateProvider: ActiveUserStateProvider) { - this.billingAccountProfileState = activeUserStateProvider.get( + constructor(stateProvider: StateProvider) { + this.billingAccountProfileState = stateProvider.getActive( BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, ); - this.hasPremiumFromAnyOrganization$ = this.billingAccountProfileState.state$.pipe( + // Setup an observable that will always track the currently active user + // but will fallback to emitting null when there is no active user. + const billingAccountProfileOrNull = stateProvider.activeUserId$.pipe( + switchMap((userId) => + userId != null + ? stateProvider.getUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION).state$ + : of(null), + ), + ); + + this.hasPremiumFromAnyOrganization$ = billingAccountProfileOrNull.pipe( map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumFromAnyOrganization), ); - this.hasPremiumPersonally$ = this.billingAccountProfileState.state$.pipe( + this.hasPremiumPersonally$ = billingAccountProfileOrNull.pipe( map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumPersonally), ); - this.hasPremiumFromAnySource$ = this.billingAccountProfileState.state$.pipe( + this.hasPremiumFromAnySource$ = billingAccountProfileOrNull.pipe( map( (billingAccountProfile) => - billingAccountProfile?.hasPremiumFromAnyOrganization || - billingAccountProfile?.hasPremiumPersonally, + billingAccountProfile?.hasPremiumFromAnyOrganization === true || + billingAccountProfile?.hasPremiumPersonally === true, ), ); } diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index 3d0ff550ea6..48866ab90d1 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -2,6 +2,8 @@ import { ApiService } from "../../abstractions/api.service"; import { BillingApiServiceAbstraction } from "../../billing/abstractions/billilng-api.service.abstraction"; import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response"; +import { ProviderSubscriptionUpdateRequest } from "../models/request/provider-subscription-update.request"; +import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response"; export class BillingApiService implements BillingApiServiceAbstraction { constructor(private apiService: ApiService) {} @@ -34,4 +36,29 @@ export class BillingApiService implements BillingApiServiceAbstraction { return new OrganizationBillingStatusResponse(r); } + + async getProviderClientSubscriptions(providerId: string): Promise { + const r = await this.apiService.send( + "GET", + "/providers/" + providerId + "/billing/subscription", + null, + true, + true, + ); + return new ProviderSubscriptionResponse(r); + } + + async putProviderClientSubscriptions( + providerId: string, + organizationId: string, + request: ProviderSubscriptionUpdateRequest, + ): Promise { + return await this.apiService.send( + "PUT", + "/providers/" + providerId + "/organizations/" + organizationId, + request, + true, + false, + ); + } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 8a5075e96f3..9470db94474 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -2,12 +2,12 @@ export enum FeatureFlag { BrowserFilelessImport = "browser-fileless-import", ItemShare = "item-share", FlexibleCollectionsV1 = "flexible-collections-v-1", // v-1 is intentional - BulkCollectionAccess = "bulk-collection-access", VaultOnboarding = "vault-onboarding", GeneratorToolsModernization = "generator-tools-modernization", KeyRotationImprovements = "key-rotation-improvements", FlexibleCollectionsMigration = "flexible-collections-migration", ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners", + EnableConsolidatedBilling = "enable-consolidated-billing", } // Replace this with a type safe lookup of the feature flag values in PM-2282 diff --git a/libs/common/src/platform/abstractions/app-id.service.ts b/libs/common/src/platform/abstractions/app-id.service.ts index c1414dd01ff..c2c1a23ef5e 100644 --- a/libs/common/src/platform/abstractions/app-id.service.ts +++ b/libs/common/src/platform/abstractions/app-id.service.ts @@ -1,8 +1,8 @@ import { Observable } from "rxjs"; export abstract class AppIdService { - appId$: Observable; - anonymousAppId$: Observable; - getAppId: () => Promise; - getAnonymousAppId: () => Promise; + abstract appId$: Observable; + abstract anonymousAppId$: Observable; + abstract getAppId(): Promise; + abstract getAnonymousAppId(): Promise; } diff --git a/libs/common/src/platform/abstractions/broadcaster.service.ts b/libs/common/src/platform/abstractions/broadcaster.service.ts index 5df3c033433..8abfb5a90c5 100644 --- a/libs/common/src/platform/abstractions/broadcaster.service.ts +++ b/libs/common/src/platform/abstractions/broadcaster.service.ts @@ -9,13 +9,13 @@ export abstract class BroadcasterService { /** * @deprecated Use the observable from the appropriate service instead. */ - send: (message: MessageBase, id?: string) => void; + abstract send(message: MessageBase, id?: string): void; /** * @deprecated Use the observable from the appropriate service instead. */ - subscribe: (id: string, messageCallback: (message: MessageBase) => void) => void; + abstract subscribe(id: string, messageCallback: (message: MessageBase) => void): void; /** * @deprecated Use the observable from the appropriate service instead. */ - unsubscribe: (id: string) => void; + abstract unsubscribe(id: string): void; } diff --git a/libs/common/src/platform/abstractions/config/config-api.service.abstraction.ts b/libs/common/src/platform/abstractions/config/config-api.service.abstraction.ts index 2b25164e7cf..3c191f59ccc 100644 --- a/libs/common/src/platform/abstractions/config/config-api.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/config/config-api.service.abstraction.ts @@ -1,5 +1,9 @@ +import { UserId } from "../../../types/guid"; import { ServerConfigResponse } from "../../models/response/server-config.response"; export abstract class ConfigApiServiceAbstraction { - get: () => Promise; + /** + * Fetches the server configuration for the given user. If no user is provided, the configuration will not contain user-specific context. + */ + abstract get(userId: UserId | undefined): Promise; } diff --git a/libs/common/src/platform/abstractions/config/config.service.abstraction.ts b/libs/common/src/platform/abstractions/config/config.service.abstraction.ts deleted file mode 100644 index 1e1de9155f1..00000000000 --- a/libs/common/src/platform/abstractions/config/config.service.abstraction.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Observable } from "rxjs"; -import { SemVer } from "semver"; - -import { FeatureFlag } from "../../../enums/feature-flag.enum"; -import { Region } from "../environment.service"; - -import { ServerConfig } from "./server-config"; - -export abstract class ConfigServiceAbstraction { - serverConfig$: Observable; - cloudRegion$: Observable; - getFeatureFlag$: ( - key: FeatureFlag, - defaultValue?: T, - ) => Observable; - getFeatureFlag: ( - key: FeatureFlag, - defaultValue?: T, - ) => Promise; - checkServerMeetsVersionRequirement$: ( - minimumRequiredServerVersion: SemVer, - ) => Observable; - - /** - * Force ConfigService to fetch an updated config from the server and emit it from serverConfig$ - * @deprecated The service implementation should subscribe to an observable and use that to trigger a new fetch from - * server instead - */ - triggerServerConfigFetch: () => void; -} diff --git a/libs/common/src/platform/abstractions/config/config.service.ts b/libs/common/src/platform/abstractions/config/config.service.ts new file mode 100644 index 00000000000..9eca5891ac1 --- /dev/null +++ b/libs/common/src/platform/abstractions/config/config.service.ts @@ -0,0 +1,47 @@ +import { Observable } from "rxjs"; +import { SemVer } from "semver"; + +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { Region } from "../environment.service"; + +import { ServerConfig } from "./server-config"; + +export abstract class ConfigService { + /** The server config of the currently active user */ + serverConfig$: Observable; + /** The cloud region of the currently active user */ + cloudRegion$: Observable; + /** + * Retrieves the value of a feature flag for the currently active user + * @param key The feature flag to retrieve + * @param defaultValue The default value to return if the feature flag is not set or the server's config is irretrievable + * @returns An observable that emits the value of the feature flag, updates as the server config changes + */ + getFeatureFlag$: ( + key: FeatureFlag, + defaultValue?: T, + ) => Observable; + /** + * Retrieves the value of a feature flag for the currently active user + * @param key The feature flag to retrieve + * @param defaultValue The default value to return if the feature flag is not set or the server's config is irretrievable + * @returns The value of the feature flag + */ + getFeatureFlag: ( + key: FeatureFlag, + defaultValue?: T, + ) => Promise; + /** + * Verifies whether the server version meets the minimum required version + * @param minimumRequiredServerVersion The minimum version required + * @returns True if the server version is greater than or equal to the minimum required version + */ + checkServerMeetsVersionRequirement$: ( + minimumRequiredServerVersion: SemVer, + ) => Observable; + + /** + * Triggers a check that the config for the currently active user is up-to-date. If it is not, it will be fetched from the server and stored. + */ + abstract ensureConfigFetched(): Promise; +} diff --git a/libs/common/src/platform/abstractions/config/server-config.ts b/libs/common/src/platform/abstractions/config/server-config.ts index 2fa250202e4..287e359f189 100644 --- a/libs/common/src/platform/abstractions/config/server-config.ts +++ b/libs/common/src/platform/abstractions/config/server-config.ts @@ -7,7 +7,6 @@ import { } from "../../models/data/server-config.data"; const dayInMilliseconds = 24 * 3600 * 1000; -const eighteenHoursInMilliseconds = 18 * 3600 * 1000; export class ServerConfig { version: string; @@ -38,10 +37,6 @@ export class ServerConfig { return this.getAgeInMilliseconds() <= dayInMilliseconds; } - expiresSoon(): boolean { - return this.getAgeInMilliseconds() >= eighteenHoursInMilliseconds; - } - static fromJSON(obj: Jsonify): ServerConfig { if (obj == null) { return null; diff --git a/libs/common/src/platform/abstractions/crypto-function.service.ts b/libs/common/src/platform/abstractions/crypto-function.service.ts index db432abc34c..18c14677dd0 100644 --- a/libs/common/src/platform/abstractions/crypto-function.service.ts +++ b/libs/common/src/platform/abstractions/crypto-function.service.ts @@ -3,85 +3,85 @@ import { DecryptParameters } from "../models/domain/decrypt-parameters"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; export abstract class CryptoFunctionService { - pbkdf2: ( + abstract pbkdf2( password: string | Uint8Array, salt: string | Uint8Array, algorithm: "sha256" | "sha512", iterations: number, - ) => Promise; - argon2: ( + ): Promise; + abstract argon2( password: string | Uint8Array, salt: string | Uint8Array, iterations: number, memory: number, parallelism: number, - ) => Promise; - hkdf: ( + ): Promise; + abstract hkdf( ikm: Uint8Array, salt: string | Uint8Array, info: string | Uint8Array, outputByteSize: number, algorithm: "sha256" | "sha512", - ) => Promise; - hkdfExpand: ( + ): Promise; + abstract hkdfExpand( prk: Uint8Array, info: string | Uint8Array, outputByteSize: number, algorithm: "sha256" | "sha512", - ) => Promise; - hash: ( + ): Promise; + abstract hash( value: string | Uint8Array, algorithm: "sha1" | "sha256" | "sha512" | "md5", - ) => Promise; - hmac: ( + ): Promise; + abstract hmac( value: Uint8Array, key: Uint8Array, algorithm: "sha1" | "sha256" | "sha512", - ) => Promise; - compare: (a: Uint8Array, b: Uint8Array) => Promise; - hmacFast: ( + ): Promise; + abstract compare(a: Uint8Array, b: Uint8Array): Promise; + abstract hmacFast( value: Uint8Array | string, key: Uint8Array | string, algorithm: "sha1" | "sha256" | "sha512", - ) => Promise; - compareFast: (a: Uint8Array | string, b: Uint8Array | string) => Promise; - aesEncrypt: (data: Uint8Array, iv: Uint8Array, key: Uint8Array) => Promise; - aesDecryptFastParameters: ( + ): Promise; + abstract compareFast(a: Uint8Array | string, b: Uint8Array | string): Promise; + abstract aesEncrypt(data: Uint8Array, iv: Uint8Array, key: Uint8Array): Promise; + abstract aesDecryptFastParameters( data: string, iv: string, mac: string, key: SymmetricCryptoKey, - ) => DecryptParameters; - aesDecryptFast: ( + ): DecryptParameters; + abstract aesDecryptFast( parameters: DecryptParameters, mode: "cbc" | "ecb", - ) => Promise; - aesDecrypt: ( + ): Promise; + abstract aesDecrypt( data: Uint8Array, iv: Uint8Array, key: Uint8Array, mode: "cbc" | "ecb", - ) => Promise; - rsaEncrypt: ( + ): Promise; + abstract rsaEncrypt( data: Uint8Array, publicKey: Uint8Array, algorithm: "sha1" | "sha256", - ) => Promise; - rsaDecrypt: ( + ): Promise; + abstract rsaDecrypt( data: Uint8Array, privateKey: Uint8Array, algorithm: "sha1" | "sha256", - ) => Promise; - rsaExtractPublicKey: (privateKey: Uint8Array) => Promise; - rsaGenerateKeyPair: (length: 1024 | 2048 | 4096) => Promise<[Uint8Array, Uint8Array]>; + ): Promise; + abstract rsaExtractPublicKey(privateKey: Uint8Array): Promise; + abstract rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]>; /** * Generates a key of the given length suitable for use in AES encryption */ - aesGenerateKey: (bitLength: 128 | 192 | 256 | 512) => Promise; + abstract aesGenerateKey(bitLength: 128 | 192 | 256 | 512): Promise; /** * Generates a random array of bytes of the given length. Uses a cryptographically secure random number generator. * * Do not use this for generating encryption keys. Use aesGenerateKey or rsaGenerateKeyPair instead. */ - randomBytes: (length: number) => Promise; + abstract randomBytes(length: number): Promise; } diff --git a/libs/common/src/platform/abstractions/crypto.service.ts b/libs/common/src/platform/abstractions/crypto.service.ts index a5a2d452334..85b2bfe82e7 100644 --- a/libs/common/src/platform/abstractions/crypto.service.ts +++ b/libs/common/src/platform/abstractions/crypto.service.ts @@ -12,7 +12,15 @@ import { EncString } from "../models/domain/enc-string"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; export abstract class CryptoService { - activeUserKey$: Observable; + abstract activeUserKey$: Observable; + + /** + * Returns the an observable key for the given user id. + * + * @note this observable represents only user keys stored in memory. A null value does not indicate that we cannot load a user key from storage. + * @param userId The desired user + */ + abstract getInMemoryUserKeyFor$(userId: UserId): Observable; /** * Sets the provided user key and stores * any other necessary versions (such as auto, biometrics, @@ -22,105 +30,105 @@ export abstract class CryptoService { * @param key The user key to set * @param userId The desired user */ - setUserKey: (key: UserKey, userId?: string) => Promise; + abstract setUserKey(key: UserKey, userId?: string): Promise; /** * Gets the user key from memory and sets it again, * kicking off a refresh of any additional keys * (such as auto, biometrics, or pin) */ - refreshAdditionalKeys: () => Promise; + abstract refreshAdditionalKeys(): Promise; /** * Observable value that returns whether or not the currently active user has ever had auser key, * i.e. has ever been unlocked/decrypted. This is key for differentiating between TDE locked and standard locked states. */ - everHadUserKey$: Observable; + abstract everHadUserKey$: Observable; /** * Retrieves the user key * @param userId The desired user * @returns The user key */ - getUserKey: (userId?: string) => Promise; + abstract getUserKey(userId?: string): Promise; /** * Checks if the user is using an old encryption scheme that used the master key * for encryption of data instead of the user key. */ - isLegacyUser: (masterKey?: MasterKey, userId?: string) => Promise; + abstract isLegacyUser(masterKey?: MasterKey, userId?: string): Promise; /** * Use for encryption/decryption of data in order to support legacy * encryption models. It will return the user key if available, * if not it will return the master key. * @param userId The desired user */ - getUserKeyWithLegacySupport: (userId?: string) => Promise; + abstract getUserKeyWithLegacySupport(userId?: string): Promise; /** * Retrieves the user key from storage * @param keySuffix The desired version of the user's key to retrieve * @param userId The desired user * @returns The user key */ - getUserKeyFromStorage: (keySuffix: KeySuffixOptions, userId?: string) => Promise; + abstract getUserKeyFromStorage(keySuffix: KeySuffixOptions, userId?: string): Promise; /** * Determines whether the user key is available for the given user. * @param userId The desired user. If not provided, the active user will be used. If no active user exists, the method will return false. * @returns True if the user key is available */ - hasUserKey: (userId?: UserId) => Promise; + abstract hasUserKey(userId?: UserId): Promise; /** * Determines whether the user key is available for the given user in memory. * @param userId The desired user. If not provided, the active user will be used. If no active user exists, the method will return false. * @returns True if the user key is available */ - hasUserKeyInMemory: (userId?: string) => Promise; + abstract hasUserKeyInMemory(userId?: string): Promise; /** * @param keySuffix The desired version of the user's key to check * @param userId The desired user * @returns True if the provided version of the user key is stored */ - hasUserKeyStored: (keySuffix: KeySuffixOptions, userId?: string) => Promise; + abstract hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: string): Promise; /** * Generates a new user key * @param masterKey The user's master key * @returns A new user key and the master key protected version of it */ - makeUserKey: (key: MasterKey) => Promise<[UserKey, EncString]>; + abstract makeUserKey(key: MasterKey): Promise<[UserKey, EncString]>; /** * Clears the user key * @param clearStoredKeys Clears all stored versions of the user keys as well, * such as the biometrics key * @param userId The desired user */ - clearUserKey: (clearSecretStorage?: boolean, userId?: string) => Promise; + abstract clearUserKey(clearSecretStorage?: boolean, userId?: string): Promise; /** * Clears the user's stored version of the user key * @param keySuffix The desired version of the key to clear * @param userId The desired user */ - clearStoredUserKey: (keySuffix: KeySuffixOptions, userId?: string) => Promise; + abstract clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: string): Promise; /** * Stores the master key encrypted user key * @param userKeyMasterKey The master key encrypted user key to set * @param userId The desired user */ - setMasterKeyEncryptedUserKey: (UserKeyMasterKey: string, userId?: string) => Promise; + abstract setMasterKeyEncryptedUserKey(UserKeyMasterKey: string, userId?: string): Promise; /** * Sets the user's master key * @param key The user's master key to set * @param userId The desired user */ - setMasterKey: (key: MasterKey, userId?: string) => Promise; + abstract setMasterKey(key: MasterKey, userId?: string): Promise; /** * @param userId The desired user * @returns The user's master key */ - getMasterKey: (userId?: string) => Promise; + abstract getMasterKey(userId?: string): Promise; /** * @param password The user's master password that will be used to derive a master key if one isn't found * @param userId The desired user */ - getOrDeriveMasterKey: (password: string, userId?: string) => Promise; + abstract getOrDeriveMasterKey(password: string, userId?: string): Promise; /** * Generates a master key from the provided password * @param password The user's master password @@ -129,17 +137,17 @@ export abstract class CryptoService { * @param KdfConfig The user's key derivation function configuration * @returns A master key derived from the provided password */ - makeMasterKey: ( + abstract makeMasterKey( password: string, email: string, kdf: KdfType, KdfConfig: KdfConfig, - ) => Promise; + ): Promise; /** * Clears the user's master key * @param userId The desired user */ - clearMasterKey: (userId?: string) => Promise; + abstract clearMasterKey(userId?: string): Promise; /** * Encrypts the existing (or provided) user key with the * provided master key @@ -147,10 +155,10 @@ export abstract class CryptoService { * @param userKey The user key * @returns The user key and the master key protected version of it */ - encryptUserKeyWithMasterKey: ( + abstract encryptUserKeyWithMasterKey( masterKey: MasterKey, userKey?: UserKey, - ) => Promise<[UserKey, EncString]>; + ): Promise<[UserKey, EncString]>; /** * Decrypts the user key with the provided master key * @param masterKey The user's master key @@ -158,11 +166,11 @@ export abstract class CryptoService { * @param userId The desired user * @returns The user key */ - decryptUserKeyWithMasterKey: ( + abstract decryptUserKeyWithMasterKey( masterKey: MasterKey, userKey?: EncString, userId?: string, - ) => Promise; + ): Promise; /** * Creates a master password hash from the user's master password. Can * be used for local authentication or for server authentication depending @@ -172,21 +180,25 @@ export abstract class CryptoService { * @param hashPurpose The iterations to use for the hash * @returns The user's master password hash */ - hashMasterKey: (password: string, key: MasterKey, hashPurpose?: HashPurpose) => Promise; + abstract hashMasterKey( + password: string, + key: MasterKey, + hashPurpose?: HashPurpose, + ): Promise; /** * Sets the user's master password hash * @param keyHash The user's master password hash to set */ - setMasterKeyHash: (keyHash: string) => Promise; + abstract setMasterKeyHash(keyHash: string): Promise; /** * @returns The user's master password hash */ - getMasterKeyHash: () => Promise; + abstract getMasterKeyHash(): Promise; /** * Clears the user's stored master password hash * @param userId The desired user */ - clearMasterKeyHash: (userId?: string) => Promise; + abstract clearMasterKeyHash(userId?: string): Promise; /** * Compares the provided master password to the stored password hash and server password hash. * Updates the stored hash if outdated. @@ -195,107 +207,109 @@ export abstract class CryptoService { * @returns True if the provided master password matches either the stored * key hash or the server key hash */ - compareAndUpdateKeyHash: (masterPassword: string, masterKey: MasterKey) => Promise; + abstract compareAndUpdateKeyHash(masterPassword: string, masterKey: MasterKey): Promise; /** * Stores the encrypted organization keys and clears any decrypted * organization keys currently in memory * @param orgs The organizations to set keys for * @param providerOrgs The provider organizations to set keys for */ - setOrgKeys: ( + abstract setOrgKeys( orgs: ProfileOrganizationResponse[], providerOrgs: ProfileProviderOrganizationResponse[], - ) => Promise; - activeUserOrgKeys$: Observable>; + ): Promise; + abstract activeUserOrgKeys$: Observable>; /** * Returns the organization's symmetric key * @deprecated Use the observable activeUserOrgKeys$ and `map` to the desired orgKey instead * @param orgId The desired organization * @returns The organization's symmetric key */ - getOrgKey: (orgId: string) => Promise; + abstract getOrgKey(orgId: string): Promise; /** * @deprecated Use the observable activeUserOrgKeys$ instead * @returns A record of the organization Ids to their symmetric keys */ - getOrgKeys: () => Promise>; + abstract getOrgKeys(): Promise>; /** * Uses the org key to derive a new symmetric key for encrypting data * @param orgKey The organization's symmetric key */ - makeDataEncKey: (key: T) => Promise<[SymmetricCryptoKey, EncString]>; + abstract makeDataEncKey( + key: T, + ): Promise<[SymmetricCryptoKey, EncString]>; /** * Clears the user's stored organization keys * @param memoryOnly Clear only the in-memory keys * @param userId The desired user */ - clearOrgKeys: (memoryOnly?: boolean, userId?: string) => Promise; + abstract clearOrgKeys(memoryOnly?: boolean, userId?: string): Promise; /** * Stores the encrypted provider keys and clears any decrypted * provider keys currently in memory * @param providers The providers to set keys for */ - activeUserProviderKeys$: Observable>; - setProviderKeys: (orgs: ProfileProviderResponse[]) => Promise; + abstract activeUserProviderKeys$: Observable>; + abstract setProviderKeys(orgs: ProfileProviderResponse[]): Promise; /** * @param providerId The desired provider * @returns The provider's symmetric key */ - getProviderKey: (providerId: string) => Promise; + abstract getProviderKey(providerId: string): Promise; /** * @returns A record of the provider Ids to their symmetric keys */ - getProviderKeys: () => Promise>; + abstract getProviderKeys(): Promise>; /** * @param memoryOnly Clear only the in-memory keys * @param userId The desired user */ - clearProviderKeys: (memoryOnly?: boolean, userId?: string) => Promise; + abstract clearProviderKeys(memoryOnly?: boolean, userId?: string): Promise; /** * Returns the public key from memory. If not available, extracts it * from the private key and stores it in memory * @returns The user's public key */ - getPublicKey: () => Promise; + abstract getPublicKey(): Promise; /** * Creates a new organization key and encrypts it with the user's public key. * This method can also return Provider keys for creating new Provider users. * @returns The new encrypted org key and the decrypted key itself */ - makeOrgKey: () => Promise<[EncString, T]>; + abstract makeOrgKey(): Promise<[EncString, T]>; /** * Sets the the user's encrypted private key in storage and * clears the decrypted private key from memory * Note: does not clear the private key if null is provided * @param encPrivateKey An encrypted private key */ - setPrivateKey: (encPrivateKey: string) => Promise; + abstract setPrivateKey(encPrivateKey: string): Promise; /** * Returns the private key from memory. If not available, decrypts it * from storage and stores it in memory * @returns The user's private key */ - getPrivateKey: () => Promise; + abstract getPrivateKey(): Promise; /** * Generates a fingerprint phrase for the user based on their public key * @param fingerprintMaterial Fingerprint material * @param publicKey The user's public key * @returns The user's fingerprint phrase */ - getFingerprint: (fingerprintMaterial: string, publicKey?: Uint8Array) => Promise; + abstract getFingerprint(fingerprintMaterial: string, publicKey?: Uint8Array): Promise; /** * Generates a new keypair * @param key A key to encrypt the private key with. If not provided, * defaults to the user key * @returns A new keypair: [publicKey in Base64, encrypted privateKey] */ - makeKeyPair: (key?: SymmetricCryptoKey) => Promise<[string, EncString]>; + abstract makeKeyPair(key?: SymmetricCryptoKey): Promise<[string, EncString]>; /** * Clears the user's key pair * @param memoryOnly Clear only the in-memory keys * @param userId The desired user */ - clearKeyPair: (memoryOnly?: boolean, userId?: string) => Promise; + abstract clearKeyPair(memoryOnly?: boolean, userId?: string): Promise; /** * @param pin The user's pin * @param salt The user's salt @@ -303,14 +317,19 @@ export abstract class CryptoService { * @param kdfConfig The user's kdf config * @returns A key derived from the user's pin */ - makePinKey: (pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig) => Promise; + abstract makePinKey( + pin: string, + salt: string, + kdf: KdfType, + kdfConfig: KdfConfig, + ): Promise; /** * Clears the user's pin keys from storage * Note: This will remove the stored pin and as a result, * disable pin protection for the user * @param userId The desired user */ - clearPinKeys: (userId?: string) => Promise; + abstract clearPinKeys(userId?: string): Promise; /** * Decrypts the user key with their pin * @param pin The user's PIN @@ -321,13 +340,13 @@ export abstract class CryptoService { * it will be retrieved from storage * @returns The decrypted user key */ - decryptUserKeyWithPin: ( + abstract decryptUserKeyWithPin( pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig, protectedKeyCs?: EncString, - ) => Promise; + ): Promise; /** * Creates a new Pin key that encrypts the user key instead of the * master key. Clears the old Pin key from state. @@ -340,55 +359,55 @@ export abstract class CryptoService { * places depending on if Master Password on Restart was enabled) * @returns The user key */ - decryptAndMigrateOldPinKey: ( + abstract decryptAndMigrateOldPinKey( masterPasswordOnRestart: boolean, pin: string, email: string, kdf: KdfType, kdfConfig: KdfConfig, oldPinKey: EncString, - ) => Promise; + ): Promise; /** * Replaces old master auto keys with new user auto keys */ - migrateAutoKeyIfNeeded: (userId?: string) => Promise; + abstract migrateAutoKeyIfNeeded(userId?: string): Promise; /** * @param keyMaterial The key material to derive the send key from * @returns A new send key */ - makeSendKey: (keyMaterial: Uint8Array) => Promise; + abstract makeSendKey(keyMaterial: Uint8Array): Promise; /** * Clears all of the user's keys from storage * @param userId The user's Id */ - clearKeys: (userId?: string) => Promise; + abstract clearKeys(userId?: string): Promise; /** * RSA encrypts a value. * @param data The data to encrypt * @param publicKey The public key to use for encryption, if not provided, the user's public key will be used * @returns The encrypted data */ - rsaEncrypt: (data: Uint8Array, publicKey?: Uint8Array) => Promise; + abstract rsaEncrypt(data: Uint8Array, publicKey?: Uint8Array): Promise; /** * Decrypts a value using RSA. * @param encValue The encrypted value to decrypt * @param privateKeyValue The private key to use for decryption * @returns The decrypted value */ - rsaDecrypt: (encValue: string, privateKeyValue?: Uint8Array) => Promise; - randomNumber: (min: number, max: number) => Promise; + abstract rsaDecrypt(encValue: string, privateKeyValue?: Uint8Array): Promise; + abstract randomNumber(min: number, max: number): Promise; /** * Generates a new cipher key * @returns A new cipher key */ - makeCipherKey: () => Promise; + abstract makeCipherKey(): Promise; /** * Initialize all necessary crypto keys needed for a new account. * Warning! This completely replaces any existing keys! * @returns The user's newly created public key, private key, and encrypted private key */ - initAccount: () => Promise<{ + abstract initAccount(): Promise<{ userKey: UserKey; publicKey: string; privateKey: EncString; @@ -400,18 +419,18 @@ export abstract class CryptoService { * @remarks * Should always be called before updating a users KDF config. */ - validateKdfConfig: (kdf: KdfType, kdfConfig: KdfConfig) => void; + abstract validateKdfConfig(kdf: KdfType, kdfConfig: KdfConfig): void; /** * @deprecated Left for migration purposes. Use decryptUserKeyWithPin instead. */ - decryptMasterKeyWithPin: ( + abstract decryptMasterKeyWithPin( pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig, protectedKeyCs?: EncString, - ) => Promise; + ): Promise; /** * Previously, the master key was used for any additional key like the biometrics or pin key. * We have switched to using the user key for these purposes. This method is for clearing the state @@ -419,30 +438,36 @@ export abstract class CryptoService { * @param keySuffix The desired type of key to clear * @param userId The desired user */ - clearDeprecatedKeys: (keySuffix: KeySuffixOptions, userId?: string) => Promise; + abstract clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: string): Promise; /** * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) * and then call encryptService.encrypt */ - encrypt: (plainValue: string | Uint8Array, key?: SymmetricCryptoKey) => Promise; + abstract encrypt(plainValue: string | Uint8Array, key?: SymmetricCryptoKey): Promise; /** * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) * and then call encryptService.encryptToBytes */ - encryptToBytes: (plainValue: Uint8Array, key?: SymmetricCryptoKey) => Promise; + abstract encryptToBytes( + plainValue: Uint8Array, + key?: SymmetricCryptoKey, + ): Promise; /** * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) * and then call encryptService.decryptToBytes */ - decryptToBytes: (encString: EncString, key?: SymmetricCryptoKey) => Promise; + abstract decryptToBytes(encString: EncString, key?: SymmetricCryptoKey): Promise; /** * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) * and then call encryptService.decryptToUtf8 */ - decryptToUtf8: (encString: EncString, key?: SymmetricCryptoKey) => Promise; + abstract decryptToUtf8(encString: EncString, key?: SymmetricCryptoKey): Promise; /** * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) * and then call encryptService.decryptToBytes */ - decryptFromBytes: (encBuffer: EncArrayBuffer, key: SymmetricCryptoKey) => Promise; + abstract decryptFromBytes( + encBuffer: EncArrayBuffer, + key: SymmetricCryptoKey, + ): Promise; } diff --git a/libs/common/src/platform/abstractions/encrypt.service.ts b/libs/common/src/platform/abstractions/encrypt.service.ts index a5120e6898f..9b4dde3676f 100644 --- a/libs/common/src/platform/abstractions/encrypt.service.ts +++ b/libs/common/src/platform/abstractions/encrypt.service.ts @@ -7,23 +7,26 @@ import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; export abstract class EncryptService { abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise; - abstract encryptToBytes: ( + abstract encryptToBytes( plainValue: Uint8Array, key?: SymmetricCryptoKey, - ) => Promise; - abstract decryptToUtf8: (encString: EncString, key: SymmetricCryptoKey) => Promise; - abstract decryptToBytes: (encThing: Encrypted, key: SymmetricCryptoKey) => Promise; - abstract rsaEncrypt: (data: Uint8Array, publicKey: Uint8Array) => Promise; - abstract rsaDecrypt: (data: EncString, privateKey: Uint8Array) => Promise; - abstract resolveLegacyKey: (key: SymmetricCryptoKey, encThing: Encrypted) => SymmetricCryptoKey; - abstract decryptItems: ( + ): Promise; + abstract decryptToUtf8(encString: EncString, key: SymmetricCryptoKey): Promise; + abstract decryptToBytes(encThing: Encrypted, key: SymmetricCryptoKey): Promise; + abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise; + abstract rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise; + abstract resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey; + abstract decryptItems( items: Decryptable[], key: SymmetricCryptoKey, - ) => Promise; + ): Promise; /** * Generates a base64-encoded hash of the given value * @param value The value to hash * @param algorithm The hashing algorithm to use */ - hash: (value: string | Uint8Array, algorithm: "sha1" | "sha256" | "sha512") => Promise; + abstract hash( + value: string | Uint8Array, + algorithm: "sha1" | "sha256" | "sha512", + ): Promise; } diff --git a/libs/common/src/platform/abstractions/file-download/file-download.service.ts b/libs/common/src/platform/abstractions/file-download/file-download.service.ts index 44d082d72bf..8bb70483eb7 100644 --- a/libs/common/src/platform/abstractions/file-download/file-download.service.ts +++ b/libs/common/src/platform/abstractions/file-download/file-download.service.ts @@ -1,5 +1,5 @@ import { FileDownloadRequest } from "./file-download.request"; export abstract class FileDownloadService { - download: (request: FileDownloadRequest) => void; + abstract download(request: FileDownloadRequest): void; } diff --git a/libs/common/src/platform/abstractions/file-upload/file-upload.service.ts b/libs/common/src/platform/abstractions/file-upload/file-upload.service.ts index e6a323817c9..5f26a666206 100644 --- a/libs/common/src/platform/abstractions/file-upload/file-upload.service.ts +++ b/libs/common/src/platform/abstractions/file-upload/file-upload.service.ts @@ -3,12 +3,12 @@ import { EncArrayBuffer } from "../../models/domain/enc-array-buffer"; import { EncString } from "../../models/domain/enc-string"; export abstract class FileUploadService { - upload: ( + abstract upload( uploadData: { url: string; fileUploadType: FileUploadType }, fileName: EncString, encryptedFileData: EncArrayBuffer, fileUploadMethods: FileUploadApiMethods, - ) => Promise; + ): Promise; } export type FileUploadApiMethods = { diff --git a/libs/common/src/platform/abstractions/i18n.service.ts b/libs/common/src/platform/abstractions/i18n.service.ts index 7b6eb9edc8a..a1b44d956a9 100644 --- a/libs/common/src/platform/abstractions/i18n.service.ts +++ b/libs/common/src/platform/abstractions/i18n.service.ts @@ -3,8 +3,8 @@ import { Observable } from "rxjs"; import { TranslationService } from "./translation.service"; export abstract class I18nService extends TranslationService { - userSetLocale$: Observable; - locale$: Observable; + abstract userSetLocale$: Observable; + abstract locale$: Observable; abstract setLocale(locale: string): Promise; abstract init(): Promise; } diff --git a/libs/common/src/platform/abstractions/key-generation.service.ts b/libs/common/src/platform/abstractions/key-generation.service.ts index a015182f89f..223eb75038f 100644 --- a/libs/common/src/platform/abstractions/key-generation.service.ts +++ b/libs/common/src/platform/abstractions/key-generation.service.ts @@ -11,7 +11,7 @@ export abstract class KeyGenerationService { * 512 bits = 64 bytes * @returns Generated key. */ - createKey: (bitLength: 256 | 512) => Promise; + abstract createKey(bitLength: 256 | 512): Promise; /** * Generates key material from CSPRNG and derives a 64 byte key from it. * Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869} @@ -22,11 +22,11 @@ export abstract class KeyGenerationService { * @param salt Optional. If not provided will be generated from CSPRNG. * @returns An object containing the salt, key material, and derived key. */ - createKeyWithPurpose: ( + abstract createKeyWithPurpose( bitLength: 128 | 192 | 256 | 512, purpose: string, salt?: string, - ) => Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }>; + ): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }>; /** * Derives a 64 byte key from key material. * @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}. @@ -37,11 +37,11 @@ export abstract class KeyGenerationService { * Different purposes results in different keys, even with the same material. * @returns 64 byte derived key. */ - deriveKeyFromMaterial: ( + abstract deriveKeyFromMaterial( material: CsprngArray, salt: string, purpose: string, - ) => Promise; + ): Promise; /** * Derives a 32 byte key from a password using a key derivation function. * @param password Password to derive the key from. @@ -50,10 +50,10 @@ export abstract class KeyGenerationService { * @param kdfConfig Configuration for the key derivation function. * @returns 32 byte derived key. */ - deriveKeyFromPassword: ( + abstract deriveKeyFromPassword( password: string | Uint8Array, salt: string | Uint8Array, kdf: KdfType, kdfConfig: KdfConfig, - ) => Promise; + ): Promise; } diff --git a/libs/common/src/platform/abstractions/log.service.ts b/libs/common/src/platform/abstractions/log.service.ts index 17db5976871..dffa3ca8d3e 100644 --- a/libs/common/src/platform/abstractions/log.service.ts +++ b/libs/common/src/platform/abstractions/log.service.ts @@ -1,9 +1,9 @@ import { LogLevelType } from "../enums/log-level-type.enum"; export abstract class LogService { - debug: (message: string) => void; - info: (message: string) => void; - warning: (message: string) => void; - error: (message: string) => void; - write: (level: LogLevelType, message: string) => void; + abstract debug(message: string): void; + abstract info(message: string): void; + abstract warning(message: string): void; + abstract error(message: string): void; + abstract write(level: LogLevelType, message: string): void; } diff --git a/libs/common/src/platform/abstractions/messaging.service.ts b/libs/common/src/platform/abstractions/messaging.service.ts index 7c5f05f9198..ab4332c2839 100644 --- a/libs/common/src/platform/abstractions/messaging.service.ts +++ b/libs/common/src/platform/abstractions/messaging.service.ts @@ -1,3 +1,3 @@ export abstract class MessagingService { - send: (subscriber: string, arg?: any) => void; + abstract send(subscriber: string, arg?: any): void; } diff --git a/libs/common/src/platform/abstractions/platform-utils.service.ts b/libs/common/src/platform/abstractions/platform-utils.service.ts index 0053b7d1d7c..d518a17f7b4 100644 --- a/libs/common/src/platform/abstractions/platform-utils.service.ts +++ b/libs/common/src/platform/abstractions/platform-utils.service.ts @@ -12,34 +12,34 @@ export type ClipboardOptions = { }; export abstract class PlatformUtilsService { - getDevice: () => DeviceType; - getDeviceString: () => string; - getClientType: () => ClientType; - isFirefox: () => boolean; - isChrome: () => boolean; - isEdge: () => boolean; - isOpera: () => boolean; - isVivaldi: () => boolean; - isSafari: () => boolean; - isMacAppStore: () => boolean; - isViewOpen: () => Promise; - launchUri: (uri: string, options?: any) => void; - getApplicationVersion: () => Promise; - getApplicationVersionNumber: () => Promise; - supportsWebAuthn: (win: Window) => boolean; - supportsDuo: () => boolean; - showToast: ( + abstract getDevice(): DeviceType; + abstract getDeviceString(): string; + abstract getClientType(): ClientType; + abstract isFirefox(): boolean; + abstract isChrome(): boolean; + abstract isEdge(): boolean; + abstract isOpera(): boolean; + abstract isVivaldi(): boolean; + abstract isSafari(): boolean; + abstract isMacAppStore(): boolean; + abstract isViewOpen(): Promise; + abstract launchUri(uri: string, options?: any): void; + abstract getApplicationVersion(): Promise; + abstract getApplicationVersionNumber(): Promise; + abstract supportsWebAuthn(win: Window): boolean; + abstract supportsDuo(): boolean; + abstract showToast( type: "error" | "success" | "warning" | "info", title: string, text: string | string[], options?: ToastOptions, - ) => void; - isDev: () => boolean; - isSelfHost: () => boolean; - copyToClipboard: (text: string, options?: ClipboardOptions) => void | boolean; - readFromClipboard: () => Promise; - supportsBiometric: () => Promise; - authenticateBiometric: () => Promise; - supportsSecureStorage: () => boolean; - getAutofillKeyboardShortcut: () => Promise; + ): void; + abstract isDev(): boolean; + abstract isSelfHost(): boolean; + abstract copyToClipboard(text: string, options?: ClipboardOptions): void | boolean; + abstract readFromClipboard(): Promise; + abstract supportsBiometric(): Promise; + abstract authenticateBiometric(): Promise; + abstract supportsSecureStorage(): boolean; + abstract getAutofillKeyboardShortcut(): Promise; } diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 514689313f5..4c876316cd9 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -7,16 +7,13 @@ import { BiometricKey } from "../../auth/types/biometric-key"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../tools/generator/username"; -import { SendData } from "../../tools/send/models/data/send.data"; -import { SendView } from "../../tools/send/models/view/send.view"; import { UserId } from "../../types/guid"; -import { DeviceKey, MasterKey } from "../../types/key"; +import { MasterKey } from "../../types/key"; import { CipherData } from "../../vault/models/data/cipher.data"; import { LocalData } from "../../vault/models/data/local.data"; import { CipherView } from "../../vault/models/view/cipher.view"; import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info"; import { KdfType } from "../enums"; -import { ServerConfigData } from "../models/data/server-config.data"; import { Account } from "../models/domain/account"; import { EncString } from "../models/domain/enc-string"; import { StorageOptions } from "../models/domain/storage-options"; @@ -51,10 +48,6 @@ export abstract class StateService { getAddEditCipherInfo: (options?: StorageOptions) => Promise; setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise; - getBiometricFingerprintValidated: (options?: StorageOptions) => Promise; - setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise; - getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise; - setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise; /** * Gets the user's master key */ @@ -156,27 +149,13 @@ export abstract class StateService { * @deprecated For migration purposes only, use setDecryptedUserKeyPin instead */ setDecryptedPinProtected: (value: EncString, options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this directly, use SendService - */ - getDecryptedSends: (options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this directly, use SendService - */ - setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise; - getDisableGa: (options?: StorageOptions) => Promise; - setDisableGa: (value: boolean, options?: StorageOptions) => Promise; getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise; setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise; - getDeviceKey: (options?: StorageOptions) => Promise; - setDeviceKey: (value: DeviceKey | null, options?: StorageOptions) => Promise; getAdminAuthRequest: (options?: StorageOptions) => Promise; setAdminAuthRequest: ( adminAuthRequest: AdminAuthRequestStorable, options?: StorageOptions, ) => Promise; - getShouldTrustDevice: (options?: StorageOptions) => Promise; - setShouldTrustDevice: (value: boolean, options?: StorageOptions) => Promise; getEmail: (options?: StorageOptions) => Promise; setEmail: (value: string, options?: StorageOptions) => Promise; getEmailVerified: (options?: StorageOptions) => Promise; @@ -208,14 +187,6 @@ export abstract class StateService { * @deprecated For migration purposes only, use setEncryptedUserKeyPin instead */ setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this directly, use SendService - */ - getEncryptedSends: (options?: StorageOptions) => Promise<{ [id: string]: SendData }>; - /** - * @deprecated Do not call this directly, use SendService - */ - setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise; getEverBeenUnlocked: (options?: StorageOptions) => Promise; setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise; getForceSetPasswordReason: (options?: StorageOptions) => Promise; @@ -223,8 +194,6 @@ export abstract class StateService { value: ForceSetPasswordReason, options?: StorageOptions, ) => Promise; - getInstalledVersion: (options?: StorageOptions) => Promise; - setInstalledVersion: (value: string, options?: StorageOptions) => Promise; getIsAuthenticated: (options?: StorageOptions) => Promise; getKdfConfig: (options?: StorageOptions) => Promise; setKdfConfig: (kdfConfig: KdfConfig, options?: StorageOptions) => Promise; @@ -265,27 +234,15 @@ export abstract class StateService { * Sets the user's Pin, encrypted by the user key */ setProtectedPin: (value: string, options?: StorageOptions) => Promise; - getRememberedEmail: (options?: StorageOptions) => Promise; - setRememberedEmail: (value: string, options?: StorageOptions) => Promise; getSecurityStamp: (options?: StorageOptions) => Promise; setSecurityStamp: (value: string, options?: StorageOptions) => Promise; getUserId: (options?: StorageOptions) => Promise; - getUsesKeyConnector: (options?: StorageOptions) => Promise; - setUsesKeyConnector: (value: boolean, options?: StorageOptions) => Promise; getVaultTimeout: (options?: StorageOptions) => Promise; setVaultTimeout: (value: number, options?: StorageOptions) => Promise; getVaultTimeoutAction: (options?: StorageOptions) => Promise; setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise; getApproveLoginRequests: (options?: StorageOptions) => Promise; setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this directly, use ConfigService - */ - getServerConfig: (options?: StorageOptions) => Promise; - /** - * @deprecated Do not call this directly, use ConfigService - */ - setServerConfig: (value: ServerConfigData, options?: StorageOptions) => Promise; /** * fetches string value of URL user tried to navigate to while unauthenticated. * @param options Defines the storage options for the URL; Defaults to session Storage. diff --git a/libs/common/src/platform/abstractions/system.service.ts b/libs/common/src/platform/abstractions/system.service.ts index 5a7e11f9a12..204e336fbf4 100644 --- a/libs/common/src/platform/abstractions/system.service.ts +++ b/libs/common/src/platform/abstractions/system.service.ts @@ -1,8 +1,8 @@ import { AuthService } from "../../auth/abstractions/auth.service"; export abstract class SystemService { - startProcessReload: (authService: AuthService) => Promise; - cancelProcessReload: () => void; - clearClipboard: (clipboardValue: string, timeoutMs?: number) => Promise; - clearPendingClipboard: () => Promise; + abstract startProcessReload(authService: AuthService): Promise; + abstract cancelProcessReload(): void; + abstract clearClipboard(clipboardValue: string, timeoutMs?: number): Promise; + abstract clearPendingClipboard(): Promise; } diff --git a/libs/common/src/platform/abstractions/translation.service.ts b/libs/common/src/platform/abstractions/translation.service.ts index 797965038a7..8a8faff1d8f 100644 --- a/libs/common/src/platform/abstractions/translation.service.ts +++ b/libs/common/src/platform/abstractions/translation.service.ts @@ -1,8 +1,8 @@ export abstract class TranslationService { - supportedTranslationLocales: string[]; - translationLocale: string; - collator: Intl.Collator; - localeNames: Map; - t: (id: string, p1?: string | number, p2?: string | number, p3?: string | number) => string; - translate: (id: string, p1?: string, p2?: string, p3?: string) => string; + abstract supportedTranslationLocales: string[]; + abstract translationLocale: string; + abstract collator: Intl.Collator; + abstract localeNames: Map; + abstract t(id: string, p1?: string | number, p2?: string | number, p3?: string | number): string; + abstract translate(id: string, p1?: string, p2?: string, p3?: string): string; } diff --git a/libs/common/src/platform/abstractions/validation.service.ts b/libs/common/src/platform/abstractions/validation.service.ts index c0985847bff..b5aa71381a2 100644 --- a/libs/common/src/platform/abstractions/validation.service.ts +++ b/libs/common/src/platform/abstractions/validation.service.ts @@ -1,3 +1,3 @@ export abstract class ValidationService { - showError: (data: any) => string[]; + abstract showError(data: any): string[]; } diff --git a/libs/common/src/platform/biometrics/biometric-state.service.ts b/libs/common/src/platform/biometrics/biometric-state.service.ts index 82c05542b4e..20bba497172 100644 --- a/libs/common/src/platform/biometrics/biometric-state.service.ts +++ b/libs/common/src/platform/biometrics/biometric-state.service.ts @@ -18,42 +18,42 @@ export abstract class BiometricStateService { /** * `true` if the currently active user has elected to store a biometric key to unlock their vault. */ - biometricUnlockEnabled$: Observable; // used to be biometricUnlock + abstract biometricUnlockEnabled$: Observable; // used to be biometricUnlock /** * If the user has elected to require a password on first unlock of an application instance, this key will store the * encrypted client key half used to unlock the vault. * * Tracks the currently active user */ - encryptedClientKeyHalf$: Observable; + abstract encryptedClientKeyHalf$: Observable; /** * whether or not a password is required on first unlock after opening the application * * tracks the currently active user */ - requirePasswordOnStart$: Observable; + abstract requirePasswordOnStart$: Observable; /** * Indicates the user has been warned about the security implications of using biometrics and, depending on the OS, * * tracks the currently active user. */ - dismissedRequirePasswordOnStartCallout$: Observable; + abstract dismissedRequirePasswordOnStartCallout$: Observable; /** * Whether the user has cancelled the biometric prompt. * * tracks the currently active user */ - promptCancelled$: Observable; + abstract promptCancelled$: Observable; /** * Whether the user has elected to automatically prompt for biometrics. * * tracks the currently active user */ - promptAutomatically$: Observable; + abstract promptAutomatically$: Observable; /** * Whether or not IPC fingerprint has been validated by the user this session. */ - fingerprintValidated$: Observable; + abstract fingerprintValidated$: Observable; /** * Updates the require password on start state for the currently active user. diff --git a/libs/common/src/platform/models/domain/account-keys.spec.ts b/libs/common/src/platform/models/domain/account-keys.spec.ts index 7041acc5bae..4a96da1b489 100644 --- a/libs/common/src/platform/models/domain/account-keys.spec.ts +++ b/libs/common/src/platform/models/domain/account-keys.spec.ts @@ -1,6 +1,4 @@ import { makeStaticByteArray } from "../../../../spec"; -import { CsprngArray } from "../../../types/csprng"; -import { DeviceKey } from "../../../types/key"; import { Utils } from "../../misc/utils"; import { AccountKeys, EncryptionPair } from "./account"; @@ -24,23 +22,6 @@ describe("AccountKeys", () => { const json = JSON.stringify(keys); expect(json).toContain('"publicKey":"hello"'); }); - - // As the accountKeys.toJSON doesn't really serialize the device key - // this method just checks the persistence of the deviceKey - it("should persist deviceKey", () => { - // Arrange - const accountKeys = new AccountKeys(); - const deviceKeyBytesLength = 64; - accountKeys.deviceKey = new SymmetricCryptoKey( - new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray, - ) as DeviceKey; - - // Act - const serializedKeys = accountKeys.toJSON(); - - // Assert - expect(serializedKeys.deviceKey).toEqual(accountKeys.deviceKey); - }); }); describe("fromJSON", () => { @@ -64,24 +45,5 @@ describe("AccountKeys", () => { } as any); expect(spy).toHaveBeenCalled(); }); - - it("should deserialize deviceKey", () => { - // Arrange - const expectedKeyB64 = - "ZJNnhx9BbJeb2EAq1hlMjqt6GFsg9G/GzoFf6SbPKsaiMhKGDcbHcwcyEg56Lh8lfilpZz4SRM6UA7oFCg+lSg=="; - - const symmetricCryptoKeyFromJsonSpy = jest.spyOn(SymmetricCryptoKey, "fromJSON"); - - // Act - const accountKeys = AccountKeys.fromJSON({ - deviceKey: { - keyB64: expectedKeyB64, - }, - } as any); - - // Assert - expect(symmetricCryptoKeyFromJsonSpy).toHaveBeenCalled(); - expect(accountKeys.deviceKey.keyB64).toEqual(expectedKeyB64); - }); }); }); diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 2657467ae6a..4ed36fd3897 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -9,8 +9,6 @@ import { PasswordGeneratorOptions, } from "../../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../../tools/generator/username/username-generation-options"; -import { SendData } from "../../../tools/send/models/data/send.data"; -import { SendView } from "../../../tools/send/models/view/send.view"; import { DeepJsonify } from "../../../types/deep-jsonify"; import { MasterKey } from "../../../types/key"; import { CipherData } from "../../../vault/models/data/cipher.data"; @@ -18,7 +16,6 @@ import { CipherView } from "../../../vault/models/view/cipher.view"; import { AddEditCipherInfo } from "../../../vault/types/add-edit-cipher-info"; import { KdfType } from "../../enums"; import { Utils } from "../../misc/utils"; -import { ServerConfigData } from "../../models/data/server-config.data"; import { EncryptedString, EncString } from "./enc-string"; import { SymmetricCryptoKey } from "./symmetric-crypto-key"; @@ -66,20 +63,12 @@ export class DataEncryptionPair { decrypted?: TDecrypted[]; } -// This is a temporary structure to handle migrated `DataEncryptionPair` to -// avoid needing a data migration at this stage. It should be replaced with -// proper data migrations when `DataEncryptionPair` is deprecated. -export class TemporaryDataEncryption { - encrypted?: { [id: string]: TEncrypted }; -} - export class AccountData { ciphers?: DataEncryptionPair = new DataEncryptionPair< CipherData, CipherView >(); localData?: any; - sends?: DataEncryptionPair = new DataEncryptionPair(); passwordGenerationHistory?: EncryptionPair< GeneratedPasswordHistory[], GeneratedPasswordHistory[] @@ -103,7 +92,6 @@ export class AccountData { export class AccountKeys { masterKey?: MasterKey; masterKeyEncryptedUserKey?: string; - deviceKey?: ReturnType; publicKey?: Uint8Array; /** @deprecated July 2023, left for migration purposes*/ @@ -133,7 +121,6 @@ export class AccountKeys { } return Object.assign(new AccountKeys(), obj, { masterKey: SymmetricCryptoKey.fromJSON(obj?.masterKey), - deviceKey: obj?.deviceKey, cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey), cryptoSymmetricKey: EncryptionPair.fromJSON( obj?.cryptoSymmetricKey, @@ -159,7 +146,6 @@ export class AccountKeys { } export class AccountProfile { - convertAccountToKeyConnector?: boolean; name?: string; email?: string; emailVerified?: boolean; @@ -167,7 +153,6 @@ export class AccountProfile { forceSetPasswordReason?: ForceSetPasswordReason; lastSync?: string; userId?: string; - usesKeyConnector?: boolean; keyHash?: string; kdfIterations?: number; kdfMemory?: number; @@ -185,8 +170,6 @@ export class AccountProfile { export class AccountSettings { defaultUriMatch?: UriMatchStrategySetting; - disableGa?: boolean; - enableBiometric?: boolean; minimizeOnCopyToClipboard?: boolean; passwordGenerationOptions?: PasswordGeneratorOptions; usernameGenerationOptions?: UsernameGeneratorOptions; @@ -196,10 +179,7 @@ export class AccountSettings { protectedPin?: string; vaultTimeout?: number; vaultTimeoutAction?: string = "lock"; - serverConfig?: ServerConfigData; approveLoginRequests?: boolean; - avatarColor?: string; - trustDeviceChoiceForDecryption?: boolean; /** @deprecated July 2023, left for migration purposes*/ pinProtected?: EncryptionPair = new EncryptionPair(); @@ -214,7 +194,6 @@ export class AccountSettings { obj?.pinProtected, EncString.fromJSON, ), - serverConfig: ServerConfigData.fromJSON(obj?.serverConfig), }); } } diff --git a/libs/common/src/platform/models/domain/global-state.ts b/libs/common/src/platform/models/domain/global-state.ts index 7e35606e261..cb9e3f71b34 100644 --- a/libs/common/src/platform/models/domain/global-state.ts +++ b/libs/common/src/platform/models/domain/global-state.ts @@ -1,16 +1,7 @@ -import { ThemeType } from "../../enums"; - export class GlobalState { - installedVersion?: string; organizationInvitation?: any; - rememberedEmail?: string; - theme?: ThemeType = ThemeType.System; - twoFactorToken?: string; - biometricFingerprintValidated?: boolean; vaultTimeout?: number; vaultTimeoutAction?: string; - loginRedirect?: any; - mainWindowSize?: number; enableBrowserIntegration?: boolean; enableBrowserIntegrationFingerprint?: boolean; deepLinkRedirectUrl?: string; diff --git a/libs/common/src/platform/services/config/config-api.service.ts b/libs/common/src/platform/services/config/config-api.service.ts index 702c38f53cf..f283410acea 100644 --- a/libs/common/src/platform/services/config/config-api.service.ts +++ b/libs/common/src/platform/services/config/config-api.service.ts @@ -1,18 +1,20 @@ import { ApiService } from "../../../abstractions/api.service"; -import { AuthService } from "../../../auth/abstractions/auth.service"; -import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { TokenService } from "../../../auth/abstractions/token.service"; +import { UserId } from "../../../types/guid"; import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; import { ServerConfigResponse } from "../../models/response/server-config.response"; export class ConfigApiService implements ConfigApiServiceAbstraction { constructor( private apiService: ApiService, - private authService: AuthService, + private tokenService: TokenService, ) {} - async get(): Promise { + async get(userId: UserId | undefined): Promise { + // Authentication adds extra context to config responses, if the user has an access token, we want to use it + // We don't particularly care about ensuring the token is valid and not expired, just that it exists const authed: boolean = - (await this.authService.getAuthStatus()) !== AuthenticationStatus.LoggedOut; + userId == null ? false : (await this.tokenService.getAccessToken(userId)) != null; const r = await this.apiService.send("GET", "/config", null, authed, true); return new ServerConfigResponse(r); diff --git a/libs/common/src/platform/services/config/config.service.spec.ts b/libs/common/src/platform/services/config/config.service.spec.ts index 7f337f33224..d643311a26f 100644 --- a/libs/common/src/platform/services/config/config.service.spec.ts +++ b/libs/common/src/platform/services/config/config.service.spec.ts @@ -1,200 +1,264 @@ -import { MockProxy, mock } from "jest-mock-extended"; -import { ReplaySubject, skip, take } from "rxjs"; +/** + * need to update test environment so structuredClone works appropriately + * @jest-environment ../../libs/shared/test.environment.ts + */ -import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; -import { AuthService } from "../../../auth/abstractions/auth.service"; -import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { mock } from "jest-mock-extended"; +import { Subject, firstValueFrom, of } from "rxjs"; + +import { + FakeGlobalState, + FakeSingleUserState, + FakeStateProvider, + awaitAsync, + mockAccountServiceWith, +} from "../../../../spec"; +import { subscribeTo } from "../../../../spec/observable-tracker"; import { UserId } from "../../../types/guid"; import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; import { ServerConfig } from "../../abstractions/config/server-config"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; import { LogService } from "../../abstractions/log.service"; -import { StateService } from "../../abstractions/state.service"; +import { Utils } from "../../misc/utils"; import { ServerConfigData } from "../../models/data/server-config.data"; import { EnvironmentServerConfigResponse, ServerConfigResponse, ThirdPartyServerConfigResponse, } from "../../models/response/server-config.response"; -import { StateProvider } from "../../state"; -import { ConfigService } from "./config.service"; +import { + ApiUrl, + DefaultConfigService, + RETRIEVAL_INTERVAL, + GLOBAL_SERVER_CONFIGURATIONS, + USER_SERVER_CONFIG, +} from "./default-config.service"; describe("ConfigService", () => { - let stateService: MockProxy; - let configApiService: MockProxy; - let authService: MockProxy; - let environmentService: MockProxy; - let logService: MockProxy; - let replaySubject: ReplaySubject; - let stateProvider: StateProvider; - - let serverResponseCount: number; // increments to track distinct responses received from server - - // Observables will start emitting as soon as this is created, so only create it - // after everything is mocked - const configServiceFactory = () => { - const configService = new ConfigService( - stateService, - configApiService, - authService, - environmentService, - logService, - stateProvider, - ); - configService.init(); - return configService; - }; + const configApiService = mock(); + const environmentService = mock(); + const logService = mock(); + let stateProvider: FakeStateProvider; + let globalState: FakeGlobalState>; + let userState: FakeSingleUserState; + const activeApiUrl = apiUrl(0); + const userId = "userId" as UserId; + const accountService = mockAccountServiceWith(userId); + const tooOld = new Date(Date.now() - 1.1 * RETRIEVAL_INTERVAL); beforeEach(() => { - stateService = mock(); - configApiService = mock(); - authService = mock(); - environmentService = mock(); - logService = mock(); - replaySubject = new ReplaySubject(1); - const accountService = mockAccountServiceWith("0" as UserId); stateProvider = new FakeStateProvider(accountService); - - environmentService.environment$ = replaySubject.asObservable(); - - serverResponseCount = 1; - configApiService.get.mockImplementation(() => - Promise.resolve(serverConfigResponseFactory("server" + serverResponseCount++)), - ); - - jest.useFakeTimers(); + globalState = stateProvider.global.getFake(GLOBAL_SERVER_CONFIGURATIONS); + userState = stateProvider.singleUser.getFake(userId, USER_SERVER_CONFIG); }); afterEach(() => { - jest.useRealTimers(); + jest.resetAllMocks(); }); - it("Uses storage as fallback", (done) => { - const storedConfigData = serverConfigDataFactory("storedConfig"); - stateService.getServerConfig.mockResolvedValueOnce(storedConfigData); + describe.each([null, userId])("active user: %s", (activeUserId) => { + let sut: DefaultConfigService; - configApiService.get.mockRejectedValueOnce(new Error("Unable to fetch")); - - const configService = configServiceFactory(); - - configService.serverConfig$.pipe(take(1)).subscribe((config) => { - expect(config).toEqual(new ServerConfig(storedConfigData)); - expect(stateService.getServerConfig).toHaveBeenCalledTimes(1); - expect(stateService.setServerConfig).not.toHaveBeenCalled(); - done(); + beforeAll(async () => { + await accountService.switchAccount(activeUserId); }); - configService.triggerServerConfigFetch(); - }); - - it("Stream does not error out if fetch fails", (done) => { - const storedConfigData = serverConfigDataFactory("storedConfig"); - stateService.getServerConfig.mockResolvedValueOnce(storedConfigData); - - const configService = configServiceFactory(); - - configService.serverConfig$.pipe(skip(1), take(1)).subscribe((config) => { - try { - expect(config.gitHash).toEqual("server1"); - done(); - } catch (e) { - done(e); - } - }); - - configApiService.get.mockRejectedValueOnce(new Error("Unable to fetch")); - configService.triggerServerConfigFetch(); - - configApiService.get.mockResolvedValueOnce(serverConfigResponseFactory("server1")); - configService.triggerServerConfigFetch(); - }); - - describe("Fetches config from server", () => { beforeEach(() => { - stateService.getServerConfig.mockResolvedValueOnce(null); + environmentService.environment$ = of(environmentFactory(activeApiUrl)); + sut = new DefaultConfigService( + configApiService, + environmentService, + logService, + stateProvider, + ); }); - it.each([1, 2, 3])( - "after %p hour/s", - (hours: number, done: jest.DoneCallback) => { - const configService = configServiceFactory(); + describe("serverConfig$", () => { + it.each([{}, null])("handles null stored state", async (globalTestState) => { + globalState.stateSubject.next(globalTestState); + userState.nextState(null); + await expect(firstValueFrom(sut.serverConfig$)).resolves.not.toThrow(); + }); - // skip previous hours (if any) - configService.serverConfig$.pipe(skip(hours - 1), take(1)).subscribe((config) => { - try { - expect(config.gitHash).toEqual("server" + hours); - expect(configApiService.get).toHaveBeenCalledTimes(hours); - done(); - } catch (e) { - done(e); - } + describe.each(["stale", "missing"])("%s config", (configStateDescription) => { + const userStored = + configStateDescription === "missing" + ? null + : serverConfigFactory(activeApiUrl + userId, tooOld); + const globalStored = + configStateDescription === "missing" + ? {} + : { + [activeApiUrl]: serverConfigFactory(activeApiUrl, tooOld), + }; + + beforeEach(() => { + globalState.stateSubject.next(globalStored); + userState.nextState(userStored); }); - const oneHourInMs = 1000 * 3600; - jest.advanceTimersByTime(oneHourInMs * hours + 1); - }, - ); + // sanity check + test("authed and unauthorized state are different", () => { + expect(globalStored[activeApiUrl]).not.toEqual(userStored); + }); - it("when environment URLs change", (done) => { - const configService = configServiceFactory(); + describe("fail to fetch", () => { + beforeEach(() => { + configApiService.get.mockRejectedValue(new Error("Unable to fetch")); + }); - configService.serverConfig$.pipe(take(1)).subscribe((config) => { - try { - expect(config.gitHash).toEqual("server1"); - done(); - } catch (e) { - done(e); - } + it("uses storage as fallback", async () => { + const actual = await firstValueFrom(sut.serverConfig$); + expect(actual).toEqual(activeUserId ? userStored : globalStored[activeApiUrl]); + expect(configApiService.get).toHaveBeenCalledTimes(1); + }); + + it("does not error out when fetch fails", async () => { + await expect(firstValueFrom(sut.serverConfig$)).resolves.not.toThrow(); + expect(configApiService.get).toHaveBeenCalledTimes(1); + }); + + it("logs an error when unable to fetch", async () => { + await firstValueFrom(sut.serverConfig$); + + expect(logService.error).toHaveBeenCalledWith( + `Unable to fetch ServerConfig from ${activeApiUrl}: Unable to fetch`, + ); + }); + }); + + describe("fetch success", () => { + const response = serverConfigResponseFactory(); + const newConfig = new ServerConfig(new ServerConfigData(response)); + + it("should be a new config", async () => { + expect(newConfig).not.toEqual(activeUserId ? userStored : globalStored[activeApiUrl]); + }); + + it("fetches config from server when it's older than an hour", async () => { + await firstValueFrom(sut.serverConfig$); + + expect(configApiService.get).toHaveBeenCalledTimes(1); + }); + + it("returns the updated config", async () => { + configApiService.get.mockResolvedValue(response); + + const actual = await firstValueFrom(sut.serverConfig$); + + // This is the time the response is converted to a config + expect(actual.utcDate).toAlmostEqual(newConfig.utcDate, 1000); + delete actual.utcDate; + delete newConfig.utcDate; + + expect(actual).toEqual(newConfig); + }); + }); }); - replaySubject.next(null); - }); + describe("fresh configuration", () => { + const userStored = serverConfigFactory(activeApiUrl + userId); + const globalStored = { + [activeApiUrl]: serverConfigFactory(activeApiUrl), + }; + beforeEach(() => { + globalState.stateSubject.next(globalStored); + userState.nextState(userStored); + }); + it("does not fetch from server", async () => { + await firstValueFrom(sut.serverConfig$); - it("when triggerServerConfigFetch() is called", (done) => { - const configService = configServiceFactory(); + expect(configApiService.get).not.toHaveBeenCalled(); + }); - configService.serverConfig$.pipe(take(1)).subscribe((config) => { - try { - expect(config.gitHash).toEqual("server1"); - done(); - } catch (e) { - done(e); - } + it("uses stored value", async () => { + const actual = await firstValueFrom(sut.serverConfig$); + expect(actual).toEqual(activeUserId ? userStored : globalStored[activeApiUrl]); + }); + + it("does not complete after emit", async () => { + const emissions = []; + const subscription = sut.serverConfig$.subscribe((v) => emissions.push(v)); + await awaitAsync(); + expect(emissions.length).toBe(1); + expect(subscription.closed).toBe(false); + }); }); - - configService.triggerServerConfigFetch(); }); }); - it("Saves server config to storage when the user is logged in", (done) => { - stateService.getServerConfig.mockResolvedValueOnce(null); - authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked); - const configService = configServiceFactory(); + describe("environment change", () => { + let sut: DefaultConfigService; + let environmentSubject: Subject; - configService.serverConfig$.pipe(take(1)).subscribe(() => { - try { - expect(stateService.setServerConfig).toHaveBeenCalledWith( - expect.objectContaining({ gitHash: "server1" }), - ); - done(); - } catch (e) { - done(e); - } + beforeAll(async () => { + // updating environment with an active account is undefined behavior + await accountService.switchAccount(null); }); - configService.triggerServerConfigFetch(); + beforeEach(() => { + environmentSubject = new Subject(); + environmentService.environment$ = environmentSubject; + sut = new DefaultConfigService( + configApiService, + environmentService, + logService, + stateProvider, + ); + }); + + describe("serverConfig$", () => { + it("emits a new config when the environment changes", async () => { + const globalStored = { + [apiUrl(0)]: serverConfigFactory(apiUrl(0)), + [apiUrl(1)]: serverConfigFactory(apiUrl(1)), + }; + globalState.stateSubject.next(globalStored); + + const spy = subscribeTo(sut.serverConfig$); + + environmentSubject.next(environmentFactory(apiUrl(0))); + environmentSubject.next(environmentFactory(apiUrl(1))); + + const expected = [globalStored[apiUrl(0)], globalStored[apiUrl(1)]]; + + const actual = await spy.pauseUntilReceived(2); + expect(actual.length).toBe(2); + + // validate dates this is done separately because the dates are created when ServerConfig is initialized + expect(actual[0].utcDate).toAlmostEqual(expected[0].utcDate, 1000); + expect(actual[1].utcDate).toAlmostEqual(expected[1].utcDate, 1000); + delete actual[0].utcDate; + delete actual[1].utcDate; + delete expected[0].utcDate; + delete expected[1].utcDate; + + expect(actual).toEqual(expected); + spy.unsubscribe(); + }); + }); }); }); -function serverConfigDataFactory(gitHash: string) { - return new ServerConfigData(serverConfigResponseFactory(gitHash)); +function apiUrl(count: number) { + return `https://api${count}.test.com`; } -function serverConfigResponseFactory(gitHash: string) { +function serverConfigFactory(hash: string, date: Date = new Date()) { + const config = new ServerConfig(serverConfigDataFactory(hash)); + config.utcDate = date; + return config; +} + +function serverConfigDataFactory(hash?: string) { + return new ServerConfigData(serverConfigResponseFactory(hash)); +} + +function serverConfigResponseFactory(hash?: string) { return new ServerConfigResponse({ version: "myConfigVersion", - gitHash: gitHash, + gitHash: hash ?? Utils.newGuid(), // Use optional git hash to store uniqueness value server: new ThirdPartyServerConfigResponse({ name: "myThirdPartyServer", url: "www.example.com", @@ -209,3 +273,9 @@ function serverConfigResponseFactory(gitHash: string) { }, }); } + +function environmentFactory(apiUrl: string) { + return { + getApiUrl: () => apiUrl, + } as Environment; +} diff --git a/libs/common/src/platform/services/config/config.service.ts b/libs/common/src/platform/services/config/config.service.ts deleted file mode 100644 index 86948fc1c0e..00000000000 --- a/libs/common/src/platform/services/config/config.service.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - ReplaySubject, - Subject, - catchError, - concatMap, - defer, - delayWhen, - firstValueFrom, - map, - merge, - timer, -} from "rxjs"; -import { SemVer } from "semver"; - -import { AuthService } from "../../../auth/abstractions/auth.service"; -import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; -import { FeatureFlag, FeatureFlagValue } from "../../../enums/feature-flag.enum"; -import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; -import { ConfigServiceAbstraction } from "../../abstractions/config/config.service.abstraction"; -import { ServerConfig } from "../../abstractions/config/server-config"; -import { EnvironmentService, Region } from "../../abstractions/environment.service"; -import { LogService } from "../../abstractions/log.service"; -import { StateService } from "../../abstractions/state.service"; -import { ServerConfigData } from "../../models/data/server-config.data"; -import { StateProvider } from "../../state"; - -const ONE_HOUR_IN_MILLISECONDS = 1000 * 3600; - -export class ConfigService implements ConfigServiceAbstraction { - private inited = false; - - protected _serverConfig = new ReplaySubject(1); - serverConfig$ = this._serverConfig.asObservable(); - - private _forceFetchConfig = new Subject(); - protected refreshTimer$ = timer(ONE_HOUR_IN_MILLISECONDS, ONE_HOUR_IN_MILLISECONDS); // after 1 hour, then every hour - - cloudRegion$ = this.serverConfig$.pipe( - map((config) => config?.environment?.cloudRegion ?? Region.US), - ); - - constructor( - private stateService: StateService, - private configApiService: ConfigApiServiceAbstraction, - private authService: AuthService, - private environmentService: EnvironmentService, - private logService: LogService, - private stateProvider: StateProvider, - - // Used to avoid duplicate subscriptions, e.g. in browser between the background and popup - private subscribe = true, - ) {} - - init() { - if (!this.subscribe || this.inited) { - return; - } - - const latestServerConfig$ = defer(() => this.configApiService.get()).pipe( - map((response) => new ServerConfigData(response)), - delayWhen((data) => this.saveConfig(data)), - catchError((e: unknown) => { - // fall back to stored ServerConfig (if any) - this.logService.error("Unable to fetch ServerConfig: " + (e as Error)?.message); - return this.stateService.getServerConfig(); - }), - ); - - // If you need to fetch a new config when an event occurs, add an observable that emits on that event here - merge( - this.refreshTimer$, // an overridable interval - this.environmentService.environment$, // when environment URLs change (including when app is started) - this._forceFetchConfig, // manual - ) - .pipe( - concatMap(() => latestServerConfig$), - map((data) => (data == null ? null : new ServerConfig(data))), - ) - .subscribe((config) => this._serverConfig.next(config)); - - this.inited = true; - } - - getFeatureFlag$(key: FeatureFlag, defaultValue?: T) { - return this.serverConfig$.pipe( - map((serverConfig) => { - if (serverConfig?.featureStates == null || serverConfig.featureStates[key] == null) { - return defaultValue; - } - - return serverConfig.featureStates[key] as T; - }), - ); - } - - async getFeatureFlag(key: FeatureFlag, defaultValue?: T) { - return await firstValueFrom(this.getFeatureFlag$(key, defaultValue)); - } - - triggerServerConfigFetch() { - this._forceFetchConfig.next(); - } - - private async saveConfig(data: ServerConfigData) { - if ((await this.authService.getAuthStatus()) === AuthenticationStatus.LoggedOut) { - return; - } - - const userId = await firstValueFrom(this.stateProvider.activeUserId$); - await this.stateService.setServerConfig(data); - await this.environmentService.setCloudRegion(userId, data.environment?.cloudRegion); - } - - /** - * Verifies whether the server version meets the minimum required version - * @param minimumRequiredServerVersion The minimum version required - * @returns True if the server version is greater than or equal to the minimum required version - */ - checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer) { - return this.serverConfig$.pipe( - map((serverConfig) => { - if (serverConfig == null) { - return false; - } - const serverVersion = new SemVer(serverConfig.version); - return serverVersion.compare(minimumRequiredServerVersion) >= 0; - }), - ); - } -} diff --git a/libs/common/src/platform/services/config/default-config.service.ts b/libs/common/src/platform/services/config/default-config.service.ts new file mode 100644 index 00000000000..9532b903d37 --- /dev/null +++ b/libs/common/src/platform/services/config/default-config.service.ts @@ -0,0 +1,177 @@ +import { + NEVER, + Observable, + Subject, + combineLatest, + firstValueFrom, + map, + mergeWith, + of, + shareReplay, + switchMap, + tap, +} from "rxjs"; +import { SemVer } from "semver"; + +import { FeatureFlag, FeatureFlagValue } from "../../../enums/feature-flag.enum"; +import { UserId } from "../../../types/guid"; +import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; +import { ConfigService } from "../../abstractions/config/config.service"; +import { ServerConfig } from "../../abstractions/config/server-config"; +import { EnvironmentService, Region } from "../../abstractions/environment.service"; +import { LogService } from "../../abstractions/log.service"; +import { ServerConfigData } from "../../models/data/server-config.data"; +import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state"; + +export const RETRIEVAL_INTERVAL = 3_600_000; // 1 hour + +export type ApiUrl = string; + +export const USER_SERVER_CONFIG = new UserKeyDefinition(CONFIG_DISK, "serverConfig", { + deserializer: (data) => (data == null ? null : ServerConfig.fromJSON(data)), + clearOn: ["logout"], +}); + +// TODO MDG: When to clean these up? +export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record( + CONFIG_DISK, + "byServer", + { + deserializer: (data) => (data == null ? null : ServerConfig.fromJSON(data)), + }, +); + +// FIXME: currently we are limited to api requests for active users. Update to accept a UserId and APIUrl once ApiService supports it. +export class DefaultConfigService implements ConfigService { + private failedFetchFallbackSubject = new Subject(); + + serverConfig$: Observable; + + cloudRegion$: Observable; + + constructor( + private configApiService: ConfigApiServiceAbstraction, + private environmentService: EnvironmentService, + private logService: LogService, + private stateProvider: StateProvider, + ) { + const apiUrl$ = this.environmentService.environment$.pipe( + map((environment) => environment.getApiUrl()), + ); + + this.serverConfig$ = combineLatest([this.stateProvider.activeUserId$, apiUrl$]).pipe( + switchMap(([userId, apiUrl]) => { + const config$ = + userId == null ? this.globalConfigFor$(apiUrl) : this.userConfigFor$(userId); + return config$.pipe(map((config) => [config, userId, apiUrl] as const)); + }), + tap(async (rec) => { + const [existingConfig, userId, apiUrl] = rec; + // Grab new config if older retrieval interval + if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) { + await this.renewConfig(existingConfig, userId, apiUrl); + } + }), + switchMap(([existingConfig]) => { + // If we needed to fetch, stop this emit, we'll get a new one after update + // This is split up with the above tap because we need to return an observable from a failed promise, + // which isn't very doable since promises are converted to observables in switchMap + if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) { + return NEVER; + } + return of(existingConfig); + }), + // If fetch fails, we'll emit on this subject to fallback to the existing config + mergeWith(this.failedFetchFallbackSubject), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + this.cloudRegion$ = this.serverConfig$.pipe( + map((config) => config?.environment?.cloudRegion ?? Region.US), + ); + } + getFeatureFlag$(key: FeatureFlag, defaultValue?: T) { + return this.serverConfig$.pipe( + map((serverConfig) => { + if (serverConfig?.featureStates == null || serverConfig.featureStates[key] == null) { + return defaultValue; + } + + return serverConfig.featureStates[key] as T; + }), + ); + } + + async getFeatureFlag(key: FeatureFlag, defaultValue?: T) { + return await firstValueFrom(this.getFeatureFlag$(key, defaultValue)); + } + + checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer) { + return this.serverConfig$.pipe( + map((serverConfig) => { + if (serverConfig == null) { + return false; + } + const serverVersion = new SemVer(serverConfig.version); + return serverVersion.compare(minimumRequiredServerVersion) >= 0; + }), + ); + } + + async ensureConfigFetched() { + // Triggering a retrieval for the given user ensures that the config is less than RETRIEVAL_INTERVAL old + await firstValueFrom(this.serverConfig$); + } + + private olderThanRetrievalInterval(date: Date) { + return new Date().getTime() - date.getTime() > RETRIEVAL_INTERVAL; + } + + // Updates the on-disk configuration with a newly retrieved configuration + private async renewConfig( + existingConfig: ServerConfig, + userId: UserId, + apiUrl: string, + ): Promise { + try { + const response = await this.configApiService.get(userId); + const newConfig = new ServerConfig(new ServerConfigData(response)); + + // Update the environment region + if ( + newConfig?.environment?.cloudRegion != null && + existingConfig?.environment?.cloudRegion != newConfig.environment.cloudRegion + ) { + // Null userId sets global, otherwise sets to the given user + await this.environmentService.setCloudRegion(userId, newConfig?.environment?.cloudRegion); + } + + if (userId == null) { + // update global state with new pulled config + await this.stateProvider.getGlobal(GLOBAL_SERVER_CONFIGURATIONS).update((configs) => { + return { ...configs, [apiUrl]: newConfig }; + }); + } else { + // update state with new pulled config + await this.stateProvider.setUserState(USER_SERVER_CONFIG, newConfig, userId); + } + } catch (e) { + // mutate error to be handled by catchError + this.logService.error( + `Unable to fetch ServerConfig from ${apiUrl}: ${(e as Error)?.message}`, + ); + // Emit the existing config + this.failedFetchFallbackSubject.next(existingConfig); + } + } + + private globalConfigFor$(apiUrl: string): Observable { + return this.stateProvider + .getGlobal(GLOBAL_SERVER_CONFIGURATIONS) + .state$.pipe(map((configs) => configs?.[apiUrl])); + } + + private userConfigFor$(userId: UserId): Observable { + return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$; + } +} diff --git a/libs/common/src/platform/services/crypto.service.ts b/libs/common/src/platform/services/crypto.service.ts index fbb6a852937..dd3c4974701 100644 --- a/libs/common/src/platform/services/crypto.service.ts +++ b/libs/common/src/platform/services/crypto.service.ts @@ -160,6 +160,10 @@ export class CryptoService implements CryptoServiceAbstraction { await this.setUserKey(key); } + getInMemoryUserKeyFor$(userId: UserId): Observable { + return this.stateProvider.getUserState$(USER_KEY, userId); + } + async getUserKey(userId?: UserId): Promise { let userKey = await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId)); if (userKey) { diff --git a/libs/common/src/platform/services/migration-builder.service.spec.ts b/libs/common/src/platform/services/migration-builder.service.spec.ts index c06d4bf53c7..1330ea07a42 100644 --- a/libs/common/src/platform/services/migration-builder.service.spec.ts +++ b/libs/common/src/platform/services/migration-builder.service.spec.ts @@ -82,6 +82,7 @@ describe("MigrationBuilderService", () => { startingStateVersion, new FakeStorageService(startingState), mock(), + "general", ); await sut.build().migrate(helper); diff --git a/libs/common/src/platform/services/migration-runner.ts b/libs/common/src/platform/services/migration-runner.ts index f9003434242..006031f7e54 100644 --- a/libs/common/src/platform/services/migration-runner.ts +++ b/libs/common/src/platform/services/migration-runner.ts @@ -18,6 +18,7 @@ export class MigrationRunner { await currentVersion(this.diskStorage, this.logService), this.diskStorage, this.logService, + "general", ); if (migrationHelper.currentVersion < 0) { diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 56fb91dd52b..fb62af250b4 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -11,10 +11,8 @@ import { BiometricKey } from "../../auth/types/biometric-key"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../tools/generator/username"; -import { SendData } from "../../tools/send/models/data/send.data"; -import { SendView } from "../../tools/send/models/view/send.view"; import { UserId } from "../../types/guid"; -import { DeviceKey, MasterKey } from "../../types/key"; +import { MasterKey } from "../../types/key"; import { CipherData } from "../../vault/models/data/cipher.data"; import { LocalData } from "../../vault/models/data/local.data"; import { CipherView } from "../../vault/models/view/cipher.view"; @@ -32,7 +30,6 @@ import { import { HtmlStorageLocation, KdfType, StorageLocation } from "../enums"; import { StateFactory } from "../factories/state-factory"; import { Utils } from "../misc/utils"; -import { ServerConfigData } from "../models/data/server-config.data"; import { Account, AccountData, AccountSettings } from "../models/domain/account"; import { EncString } from "../models/domain/enc-string"; import { GlobalState } from "../models/domain/global-state"; @@ -276,41 +273,6 @@ export class StateService< ); } - async getBiometricFingerprintValidated(options?: StorageOptions): Promise { - return ( - (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.biometricFingerprintValidated ?? false - ); - } - - async setBiometricFingerprintValidated(value: boolean, options?: StorageOptions): Promise { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.biometricFingerprintValidated = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - - async getConvertAccountToKeyConnector(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.profile?.convertAccountToKeyConnector; - } - - async setConvertAccountToKeyConnector(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.profile.convertAccountToKeyConnector = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - /** * @deprecated Do not save the Master Key. Use the User Symmetric Key instead */ @@ -650,42 +612,6 @@ export class StateService< ); } - @withPrototypeForArrayMembers(SendView) - async getDecryptedSends(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.data?.sends?.decrypted; - } - - async setDecryptedSends(value: SendView[], options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.data.sends.decrypted = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - - async getDisableGa(options?: StorageOptions): Promise { - return ( - (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.settings?.disableGa ?? false - ); - } - - async setDisableGa(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.settings.disableGa = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getDuckDuckGoSharedKey(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); if (options?.userId == null) { @@ -704,39 +630,6 @@ export class StateService< : await this.secureStorageService.save(DDG_SHARED_KEY, value, options); } - async getDeviceKey(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); - - if (options?.userId == null) { - return null; - } - - const account = await this.getAccount(options); - - const existingDeviceKey = account?.keys?.deviceKey; - - // Must manually instantiate the SymmetricCryptoKey class from the JSON object - if (existingDeviceKey != null) { - return SymmetricCryptoKey.fromJSON(existingDeviceKey) as DeviceKey; - } else { - return null; - } - } - - async setDeviceKey(value: DeviceKey | null, options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); - - if (options?.userId == null) { - return; - } - - const account = await this.getAccount(options); - - account.keys.deviceKey = value?.toJSON() ?? null; - - await this.saveAccount(account, options); - } - async getAdminAuthRequest(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); @@ -768,31 +661,6 @@ export class StateService< await this.saveAccount(account, options); } - async getShouldTrustDevice(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); - - if (options?.userId == null) { - return null; - } - - const account = await this.getAccount(options); - - return account?.settings?.trustDeviceChoiceForDecryption ?? null; - } - - async setShouldTrustDevice(value: boolean, options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); - if (options?.userId == null) { - return; - } - - const account = await this.getAccount(options); - - account.settings.trustDeviceChoiceForDecryption = value; - - await this.saveAccount(account, options); - } - async getEmail(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) @@ -937,27 +805,6 @@ export class StateService< ); } - @withPrototypeForObjectValues(SendData) - async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())) - )?.data?.sends.encrypted; - } - - async setEncryptedSends( - value: { [id: string]: SendData }, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - account.data.sends.encrypted = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - } - async getEverBeenUnlocked(options?: StorageOptions): Promise { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))) @@ -1000,23 +847,6 @@ export class StateService< ); } - async getInstalledVersion(options?: StorageOptions): Promise { - return ( - await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.installedVersion; - } - - async setInstalledVersion(value: string, options?: StorageOptions): Promise { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.installedVersion = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getIsAuthenticated(options?: StorageOptions): Promise { return ( (await this.tokenService.getAccessToken(options?.userId as UserId)) != null && @@ -1259,23 +1089,6 @@ export class StateService< ); } - async getRememberedEmail(options?: StorageOptions): Promise { - return ( - await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) - )?.rememberedEmail; - } - - async setRememberedEmail(value: string, options?: StorageOptions): Promise { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - globals.rememberedEmail = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - } - async getSecurityStamp(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) @@ -1299,23 +1112,6 @@ export class StateService< )?.profile?.userId; } - async getUsesKeyConnector(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.profile?.usesKeyConnector; - } - - async setUsesKeyConnector(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.profile.usesKeyConnector = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getVaultTimeout(options?: StorageOptions): Promise { const accountVaultTimeout = ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) @@ -1377,23 +1173,6 @@ export class StateService< ); } - async setServerConfig(value: ServerConfigData, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - account.settings.serverConfig = value; - return await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - } - - async getServerConfig(options: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) - )?.settings?.serverConfig; - } - async getDeepLinkRedirectUrl(options?: StorageOptions): Promise { return ( await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())) @@ -1755,7 +1534,6 @@ export class StateService< protected resetAccount(account: TAccount) { const persistentAccountInformation = { settings: account.settings, - keys: { deviceKey: account.keys.deviceKey }, adminAuthRequest: account.adminAuthRequest, }; return Object.assign(this.createAccount(), persistentAccountInformation); @@ -1781,7 +1559,9 @@ export class StateService< } protected async deAuthenticateAccount(userId: string): Promise { - await this.tokenService.clearAccessToken(userId as UserId); + // We must have a manual call to clear tokens as we can't leverage state provider to clean + // up our data as we have secure storage in the mix. + await this.tokenService.clearTokens(userId as UserId); await this.setLastActive(null, { userId: userId }); await this.updateState(async (state) => { state.authenticatedAccounts = state.authenticatedAccounts.filter((id) => id !== userId); diff --git a/libs/common/src/platform/services/translation.service.ts b/libs/common/src/platform/services/translation.service.ts index aa41073878f..4ad8162af57 100644 --- a/libs/common/src/platform/services/translation.service.ts +++ b/libs/common/src/platform/services/translation.service.ts @@ -16,6 +16,7 @@ export abstract class TranslationService implements TranslationServiceAbstractio ["bs", "bosanski jezik"], ["ca", "català"], ["cs", "čeština"], + ["cy", "Cymraeg, y Gymraeg"], ["da", "dansk"], ["de", "Deutsch"], ["el", "Ελληνικά"], @@ -30,6 +31,7 @@ export abstract class TranslationService implements TranslationServiceAbstractio ["fi", "suomi"], ["fil", "Wikang Filipino"], ["fr", "français"], + ["gl", "galego"], ["he", "עברית"], ["hi", "हिन्दी"], ["hr", "hrvatski"], @@ -45,9 +47,13 @@ export abstract class TranslationService implements TranslationServiceAbstractio ["lv", "Latvietis"], ["me", "црногорски"], ["ml", "മലയാളം"], + ["mr", "मराठी"], + ["my", "ဗမာစကား"], ["nb", "norsk (bokmål)"], + ["ne", "नेपाली"], ["nl", "Nederlands"], ["nn", "Norsk Nynorsk"], + ["or", "ଓଡ଼ିଆ"], ["pl", "polski"], ["pt-BR", "português do Brasil"], ["pt-PT", "português"], @@ -58,6 +64,7 @@ export abstract class TranslationService implements TranslationServiceAbstractio ["sl", "Slovenski jezik, Slovenščina"], ["sr", "Српски"], ["sv", "svenska"], + ["te", "తెలుగు"], ["th", "ไทย"], ["tr", "Türkçe"], ["uk", "українська"], diff --git a/libs/common/src/platform/state/derived-state.provider.ts b/libs/common/src/platform/state/derived-state.provider.ts index cf0a0c56c77..21860482479 100644 --- a/libs/common/src/platform/state/derived-state.provider.ts +++ b/libs/common/src/platform/state/derived-state.provider.ts @@ -17,9 +17,9 @@ export abstract class DerivedStateProvider { * well as some memory persistent information. * @param dependencies The dependencies of the derive function */ - get: ( + abstract get( parentState$: Observable, deriveDefinition: DeriveDefinition, dependencies: TDeps, - ) => DerivedState; + ): DerivedState; } diff --git a/libs/common/src/platform/state/global-state.provider.ts b/libs/common/src/platform/state/global-state.provider.ts index 7c791b6b4d9..5aa2b26a5b7 100644 --- a/libs/common/src/platform/state/global-state.provider.ts +++ b/libs/common/src/platform/state/global-state.provider.ts @@ -9,5 +9,5 @@ export abstract class GlobalStateProvider { * Gets a {@link GlobalState} scoped to the given {@link KeyDefinition} * @param keyDefinition - The {@link KeyDefinition} for which you want the state for. */ - get: (keyDefinition: KeyDefinition) => GlobalState; + abstract get(keyDefinition: KeyDefinition): GlobalState; } diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index bf0d162eeec..d50a3e6ac7d 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -35,15 +35,22 @@ export const BILLING_DISK = new StateDefinition("billing", "disk"); // Auth +export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk"); export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" }); +export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", { + web: "disk-local", +}); +export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory"); export const SSO_DISK = new StateDefinition("ssoLogin", "disk"); export const TOKEN_DISK = new StateDefinition("token", "disk"); export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", { web: "disk-local", }); export const TOKEN_MEMORY = new StateDefinition("token", "memory"); -export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory"); +export const DEVICE_TRUST_DISK_LOCAL = new StateDefinition("deviceTrust", "disk", { + web: "disk-local", +}); export const USER_DECRYPTION_OPTIONS_DISK = new StateDefinition("userDecryptionOptions", "disk"); // Autofill @@ -73,6 +80,9 @@ export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", }); export const BIOMETRIC_SETTINGS_DISK = new StateDefinition("biometricSettings", "disk"); export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk"); +export const CONFIG_DISK = new StateDefinition("config", "disk", { + web: "disk-local", +}); export const CRYPTO_DISK = new StateDefinition("crypto", "disk"); export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory"); export const DESKTOP_SETTINGS_DISK = new StateDefinition("desktopSettings", "disk"); @@ -92,6 +102,10 @@ export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", { export const GENERATOR_DISK = new StateDefinition("generator", "disk"); export const GENERATOR_MEMORY = new StateDefinition("generator", "memory"); export const EVENT_COLLECTION_DISK = new StateDefinition("eventCollection", "disk"); +export const SEND_DISK = new StateDefinition("encryptedSend", "disk", { + web: "memory", +}); +export const SEND_MEMORY = new StateDefinition("decryptedSend", "memory"); // Vault @@ -108,3 +122,4 @@ export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", { export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", { web: "disk-local", }); +export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory"); diff --git a/libs/common/src/platform/state/state.provider.ts b/libs/common/src/platform/state/state.provider.ts index ddbb6a7c875..a1e51552c73 100644 --- a/libs/common/src/platform/state/state.provider.ts +++ b/libs/common/src/platform/state/state.provider.ts @@ -19,7 +19,7 @@ import { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.p */ export abstract class StateProvider { /** @see{@link ActiveUserStateProvider.activeUserId$} */ - activeUserId$: Observable; + abstract activeUserId$: Observable; /** * Gets a state observable for a given key and userId. @@ -149,10 +149,10 @@ export abstract class StateProvider { ): SingleUserState; /** @see{@link GlobalStateProvider.get} */ - getGlobal: (keyDefinition: KeyDefinition) => GlobalState; - getDerived: ( + abstract getGlobal(keyDefinition: KeyDefinition): GlobalState; + abstract getDerived( parentState$: Observable, deriveDefinition: DeriveDefinition, dependencies: TDeps, - ) => DerivedState; + ): DerivedState; } diff --git a/libs/common/src/platform/state/user-state.provider.ts b/libs/common/src/platform/state/user-state.provider.ts index 2f18f3678d8..3af10218f87 100644 --- a/libs/common/src/platform/state/user-state.provider.ts +++ b/libs/common/src/platform/state/user-state.provider.ts @@ -39,7 +39,7 @@ export abstract class ActiveUserStateProvider { /** * Convenience re-emission of active user ID from {@link AccountService.activeAccount$} */ - activeUserId$: Observable; + abstract activeUserId$: Observable; /** * Gets a {@link ActiveUserState} scoped to the given {@link KeyDefinition}, but updates when active user changes such diff --git a/libs/common/src/platform/state/user-state.ts b/libs/common/src/platform/state/user-state.ts index dc994cf9fdf..44bc8732544 100644 --- a/libs/common/src/platform/state/user-state.ts +++ b/libs/common/src/platform/state/user-state.ts @@ -6,24 +6,25 @@ import { StateUpdateOptions } from "./state-update-options"; export type CombinedState = readonly [userId: UserId, state: T]; -/** - * A helper object for interacting with state that is scoped to a specific user. - */ +/** A helper object for interacting with state that is scoped to a specific user. */ export interface UserState { - /** - * Emits a stream of data. - */ - readonly state$: Observable; + /** Emits a stream of data. Emits null if the user does not have specified state. */ + readonly state$: Observable; - /** - * Emits a stream of data alongside the user id the data corresponds to. - */ + /** Emits a stream of tuples, with the first element being a user id and the second element being the data for that user. */ readonly combinedState$: Observable>; } export const activeMarker: unique symbol = Symbol("active"); export interface ActiveUserState extends UserState { readonly [activeMarker]: true; + + /** + * Emits a stream of data. Emits null if the user does not have specified state. + * Note: Will not emit if there is no active user. + */ + readonly state$: Observable; + /** * Updates backing stores for the active user. * @param configureState function that takes the current state and returns the new state diff --git a/libs/common/src/platform/theming/theme-state.service.ts b/libs/common/src/platform/theming/theme-state.service.ts index 42b5b1770cc..9c31733416b 100644 --- a/libs/common/src/platform/theming/theme-state.service.ts +++ b/libs/common/src/platform/theming/theme-state.service.ts @@ -7,13 +7,13 @@ export abstract class ThemeStateService { /** * The users selected theme. */ - selectedTheme$: Observable; + abstract selectedTheme$: Observable; /** * A method for updating the current users configured theme. * @param theme The chosen user theme. */ - setSelectedTheme: (theme: ThemeType) => Promise; + abstract setSelectedTheme(theme: ThemeType): Promise; } const THEME_SELECTION = new KeyDefinition(THEMING_DISK, "selection", { diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index b6c2ab5c223..6306eb1e288 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1780,9 +1780,9 @@ export class ApiService implements ApiServiceAbstraction { await this.tokenService.setTokens( tokenResponse.accessToken, - tokenResponse.refreshToken, vaultTimeoutAction as VaultTimeoutAction, vaultTimeout, + tokenResponse.refreshToken, ); } else { const error = await this.handleError(response, true, true); diff --git a/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts index 4eb9e776992..a8afc632972 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts @@ -52,7 +52,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA await this.stateService.setVaultTimeoutAction(action); - await this.tokenService.setTokens(accessToken, refreshToken, action, timeout, [ + await this.tokenService.setTokens(accessToken, action, timeout, refreshToken, [ clientId, clientSecret, ]); diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 1b057fda4d0..faccddb0afd 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -44,7 +44,13 @@ import { MergeEnvironmentState } from "./migrations/45-merge-environment-state"; import { DeleteBiometricPromptCancelledData } from "./migrations/46-delete-orphaned-biometric-prompt-data"; import { MoveDesktopSettingsMigrator } from "./migrations/47-move-desktop-settings"; import { MoveDdgToStateProviderMigrator } from "./migrations/48-move-ddg-to-state-provider"; +import { AccountServerConfigMigrator } from "./migrations/49-move-account-server-configs"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; +import { KeyConnectorMigrator } from "./migrations/50-move-key-connector-to-state-provider"; +import { RememberedEmailMigrator } from "./migrations/51-move-remembered-email-to-state-providers"; +import { DeleteInstalledVersion } from "./migrations/52-delete-installed-version"; +import { DeviceTrustCryptoServiceStateProviderMigrator } from "./migrations/53-migrate-device-trust-crypto-svc-to-state-providers"; +import { SendMigrator } from "./migrations/54-move-encrypted-sends"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; @@ -52,7 +58,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 48; +export const CURRENT_VERSION = 54; export type MinVersion = typeof MIN_VERSION; @@ -103,7 +109,13 @@ export function createMigrationBuilder() { .with(MergeEnvironmentState, 44, 45) .with(DeleteBiometricPromptCancelledData, 45, 46) .with(MoveDesktopSettingsMigrator, 46, 47) - .with(MoveDdgToStateProviderMigrator, 47, CURRENT_VERSION); + .with(MoveDdgToStateProviderMigrator, 47, 48) + .with(AccountServerConfigMigrator, 48, 49) + .with(KeyConnectorMigrator, 49, 50) + .with(RememberedEmailMigrator, 50, 51) + .with(DeleteInstalledVersion, 51, 52) + .with(DeviceTrustCryptoServiceStateProviderMigrator, 52, 53) + .with(SendMigrator, 53, 54); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migration-builder.spec.ts b/libs/common/src/state-migrations/migration-builder.spec.ts index f10d3b11a9a..6a4ff8e6d48 100644 --- a/libs/common/src/state-migrations/migration-builder.spec.ts +++ b/libs/common/src/state-migrations/migration-builder.spec.ts @@ -83,35 +83,35 @@ describe("MigrationBuilder", () => { }); it("should migrate", async () => { - const helper = new MigrationHelper(0, mock(), mock()); + const helper = new MigrationHelper(0, mock(), mock(), "general"); const spy = jest.spyOn(migrator, "migrate"); await sut.migrate(helper); expect(spy).toBeCalledWith(helper); }); it("should rollback", async () => { - const helper = new MigrationHelper(1, mock(), mock()); + const helper = new MigrationHelper(1, mock(), mock(), "general"); const spy = jest.spyOn(rollback_migrator, "rollback"); await sut.migrate(helper); expect(spy).toBeCalledWith(helper); }); it("should update version on migrate", async () => { - const helper = new MigrationHelper(0, mock(), mock()); + const helper = new MigrationHelper(0, mock(), mock(), "general"); const spy = jest.spyOn(migrator, "updateVersion"); await sut.migrate(helper); expect(spy).toBeCalledWith(helper, "up"); }); it("should update version on rollback", async () => { - const helper = new MigrationHelper(1, mock(), mock()); + const helper = new MigrationHelper(1, mock(), mock(), "general"); const spy = jest.spyOn(rollback_migrator, "updateVersion"); await sut.migrate(helper); expect(spy).toBeCalledWith(helper, "down"); }); it("should not run the migrator if the current version does not match the from version", async () => { - const helper = new MigrationHelper(3, mock(), mock()); + const helper = new MigrationHelper(3, mock(), mock(), "general"); const migrate = jest.spyOn(migrator, "migrate"); const rollback = jest.spyOn(rollback_migrator, "rollback"); await sut.migrate(helper); @@ -120,7 +120,7 @@ describe("MigrationBuilder", () => { }); it("should not update version if the current version does not match the from version", async () => { - const helper = new MigrationHelper(3, mock(), mock()); + const helper = new MigrationHelper(3, mock(), mock(), "general"); const migrate = jest.spyOn(migrator, "updateVersion"); const rollback = jest.spyOn(rollback_migrator, "updateVersion"); await sut.migrate(helper); @@ -130,7 +130,7 @@ describe("MigrationBuilder", () => { }); it("should be able to call instance methods", async () => { - const helper = new MigrationHelper(0, mock(), mock()); + const helper = new MigrationHelper(0, mock(), mock(), "general"); await sut.with(TestMigratorWithInstanceMethod, 0, 1).migrate(helper); }); }); diff --git a/libs/common/src/state-migrations/migration-helper.spec.ts b/libs/common/src/state-migrations/migration-helper.spec.ts index e929877b632..f86cac8768d 100644 --- a/libs/common/src/state-migrations/migration-helper.spec.ts +++ b/libs/common/src/state-migrations/migration-helper.spec.ts @@ -9,7 +9,7 @@ import { AbstractStorageService } from "../platform/abstractions/storage.service // eslint-disable-next-line import/no-restricted-paths -- Needed to generate unique strings for injection import { Utils } from "../platform/misc/utils"; -import { MigrationHelper } from "./migration-helper"; +import { MigrationHelper, MigrationHelperType } from "./migration-helper"; import { Migrator } from "./migrator"; const exampleJSON = { @@ -37,7 +37,7 @@ describe("RemoveLegacyEtmKeyMigrator", () => { storage = mock(); storage.get.mockImplementation((key) => (exampleJSON as any)[key]); - sut = new MigrationHelper(0, storage, logService); + sut = new MigrationHelper(0, storage, logService, "general"); }); describe("get", () => { @@ -150,6 +150,7 @@ describe("RemoveLegacyEtmKeyMigrator", () => { export function mockMigrationHelper( storageJson: any, stateVersion = 0, + type: MigrationHelperType = "general", ): MockProxy { const logService: MockProxy = mock(); const storage: MockProxy = mock(); @@ -157,7 +158,7 @@ export function mockMigrationHelper( storage.save.mockImplementation(async (key, value) => { (storageJson as any)[key] = value; }); - const helper = new MigrationHelper(stateVersion, storage, logService); + const helper = new MigrationHelper(stateVersion, storage, logService, type); const mockHelper = mock(); mockHelper.get.mockImplementation((key) => helper.get(key)); @@ -175,6 +176,9 @@ export function mockMigrationHelper( helper.setToUser(userId, keyDefinition, value), ); mockHelper.getAccounts.mockImplementation(() => helper.getAccounts()); + + mockHelper.type = helper.type; + return mockHelper; } @@ -291,7 +295,7 @@ export async function runMigrator< const allInjectedData = injectData(initalData, []); const fakeStorageService = new FakeStorageService(initalData); - const helper = new MigrationHelper(migrator.fromVersion, fakeStorageService, mock()); + const helper = new MigrationHelper(migrator.fromVersion, fakeStorageService, mock(), "general"); // Run their migrations if (direction === "rollback") { diff --git a/libs/common/src/state-migrations/migration-helper.ts b/libs/common/src/state-migrations/migration-helper.ts index 315a343e9e8..5b8e4ff93e5 100644 --- a/libs/common/src/state-migrations/migration-helper.ts +++ b/libs/common/src/state-migrations/migration-helper.ts @@ -9,12 +9,29 @@ export type KeyDefinitionLike = { key: string; }; +export type MigrationHelperType = "general" | "web-disk-local"; + export class MigrationHelper { constructor( public currentVersion: number, private storageService: AbstractStorageService, public logService: LogService, - ) {} + type: MigrationHelperType, + ) { + this.type = type; + } + + /** + * On some clients, migrations are ran multiple times without direct action from the migration writer. + * + * All clients will run through migrations at least once, this run is referred to as `"general"`. If a migration is + * ran more than that single time, they will get a unique name if that the write can make conditional logic based on which + * migration run this is. + * + * @remarks The preferrable way of writing migrations is ALWAYS to be defensive and reflect on the data you are given back. This + * should really only be used when reflecting on the data given isn't enough. + */ + type: MigrationHelperType; /** * Gets a value from the storage service at the given key. diff --git a/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts index a5243c261a5..7dae6eeeb6d 100644 --- a/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts +++ b/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.spec.ts @@ -124,65 +124,107 @@ describe("TokenServiceStateProviderMigrator", () => { sut = new TokenServiceStateProviderMigrator(37, 38); }); - it("should remove state service data from all accounts that have it", async () => { - await sut.migrate(helper); + describe("Session storage", () => { + it("should remove state service data from all accounts that have it", async () => { + await sut.migrate(helper); - expect(helper.set).toHaveBeenCalledWith("user1", { - tokens: { - otherStuff: "overStuff2", - }, - profile: { - email: "user1Email", - otherStuff: "overStuff3", - }, - keys: { - otherStuff: "overStuff4", - }, - otherStuff: "otherStuff5", + expect(helper.set).toHaveBeenCalledWith("user1", { + tokens: { + otherStuff: "overStuff2", + }, + profile: { + email: "user1Email", + otherStuff: "overStuff3", + }, + keys: { + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }); + + expect(helper.set).toHaveBeenCalledTimes(2); + expect(helper.set).not.toHaveBeenCalledWith("user2", any()); + expect(helper.set).not.toHaveBeenCalledWith("user3", any()); }); - expect(helper.set).toHaveBeenCalledTimes(2); - expect(helper.set).not.toHaveBeenCalledWith("user2", any()); - expect(helper.set).not.toHaveBeenCalledWith("user3", any()); + it("should migrate data to state providers for defined accounts that have the data", async () => { + await sut.migrate(helper); + + // Two factor Token Migration + expect(helper.setToGlobal).toHaveBeenLastCalledWith( + EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, + { + user1Email: "twoFactorToken", + user2Email: "twoFactorToken", + }, + ); + expect(helper.setToGlobal).toHaveBeenCalledTimes(1); + + expect(helper.setToUser).toHaveBeenCalledWith("user1", ACCESS_TOKEN_DISK, "accessToken"); + expect(helper.setToUser).toHaveBeenCalledWith("user1", REFRESH_TOKEN_DISK, "refreshToken"); + expect(helper.setToUser).toHaveBeenCalledWith( + "user1", + API_KEY_CLIENT_ID_DISK, + "apiKeyClientId", + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "user1", + API_KEY_CLIENT_SECRET_DISK, + "apiKeyClientSecret", + ); + + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", ACCESS_TOKEN_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", REFRESH_TOKEN_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", API_KEY_CLIENT_ID_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith( + "user2", + API_KEY_CLIENT_SECRET_DISK, + any(), + ); + + // Expect that we didn't migrate anything to user 3 + + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", ACCESS_TOKEN_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", REFRESH_TOKEN_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", API_KEY_CLIENT_ID_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith( + "user3", + API_KEY_CLIENT_SECRET_DISK, + any(), + ); + }); }); + describe("Local storage", () => { + beforeEach(() => { + helper = mockMigrationHelper(preMigrationJson(), 37, "web-disk-local"); + }); + it("should remove state service data from all accounts that have it", async () => { + await sut.migrate(helper); - it("should migrate data to state providers for defined accounts that have the data", async () => { - await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("user1", { + tokens: { + otherStuff: "overStuff2", + }, + profile: { + email: "user1Email", + otherStuff: "overStuff3", + }, + keys: { + otherStuff: "overStuff4", + }, + otherStuff: "otherStuff5", + }); - // Two factor Token Migration - expect(helper.setToGlobal).toHaveBeenLastCalledWith( - EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, - { - user1Email: "twoFactorToken", - user2Email: "twoFactorToken", - }, - ); - expect(helper.setToGlobal).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledTimes(2); + expect(helper.set).not.toHaveBeenCalledWith("user2", any()); + expect(helper.set).not.toHaveBeenCalledWith("user3", any()); + }); - expect(helper.setToUser).toHaveBeenCalledWith("user1", ACCESS_TOKEN_DISK, "accessToken"); - expect(helper.setToUser).toHaveBeenCalledWith("user1", REFRESH_TOKEN_DISK, "refreshToken"); - expect(helper.setToUser).toHaveBeenCalledWith( - "user1", - API_KEY_CLIENT_ID_DISK, - "apiKeyClientId", - ); - expect(helper.setToUser).toHaveBeenCalledWith( - "user1", - API_KEY_CLIENT_SECRET_DISK, - "apiKeyClientSecret", - ); + it("should not migrate any data to local storage", async () => { + await sut.migrate(helper); - expect(helper.setToUser).not.toHaveBeenCalledWith("user2", ACCESS_TOKEN_DISK, any()); - expect(helper.setToUser).not.toHaveBeenCalledWith("user2", REFRESH_TOKEN_DISK, any()); - expect(helper.setToUser).not.toHaveBeenCalledWith("user2", API_KEY_CLIENT_ID_DISK, any()); - expect(helper.setToUser).not.toHaveBeenCalledWith("user2", API_KEY_CLIENT_SECRET_DISK, any()); - - // Expect that we didn't migrate anything to user 3 - - expect(helper.setToUser).not.toHaveBeenCalledWith("user3", ACCESS_TOKEN_DISK, any()); - expect(helper.setToUser).not.toHaveBeenCalledWith("user3", REFRESH_TOKEN_DISK, any()); - expect(helper.setToUser).not.toHaveBeenCalledWith("user3", API_KEY_CLIENT_ID_DISK, any()); - expect(helper.setToUser).not.toHaveBeenCalledWith("user3", API_KEY_CLIENT_SECRET_DISK, any()); + expect(helper.setToUser).not.toHaveBeenCalled(); + }); }); }); diff --git a/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts b/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts index 17753d21879..640e63cdc54 100644 --- a/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts +++ b/libs/common/src/state-migrations/migrations/38-migrate-token-svc-to-state-provider.ts @@ -84,7 +84,10 @@ export class TokenServiceStateProviderMigrator extends Migrator<37, 38> { if (existingAccessToken != null) { // Only migrate data that exists - await helper.setToUser(userId, ACCESS_TOKEN_DISK, existingAccessToken); + if (helper.type !== "web-disk-local") { + // only migrate access token to session storage - never local. + await helper.setToUser(userId, ACCESS_TOKEN_DISK, existingAccessToken); + } delete account.tokens.accessToken; updatedAccount = true; } @@ -93,7 +96,10 @@ export class TokenServiceStateProviderMigrator extends Migrator<37, 38> { const existingRefreshToken = account?.tokens?.refreshToken; if (existingRefreshToken != null) { - await helper.setToUser(userId, REFRESH_TOKEN_DISK, existingRefreshToken); + if (helper.type !== "web-disk-local") { + // only migrate refresh token to session storage - never local. + await helper.setToUser(userId, REFRESH_TOKEN_DISK, existingRefreshToken); + } delete account.tokens.refreshToken; updatedAccount = true; } @@ -102,7 +108,10 @@ export class TokenServiceStateProviderMigrator extends Migrator<37, 38> { const existingApiKeyClientId = account?.profile?.apiKeyClientId; if (existingApiKeyClientId != null) { - await helper.setToUser(userId, API_KEY_CLIENT_ID_DISK, existingApiKeyClientId); + if (helper.type !== "web-disk-local") { + // only migrate client id to session storage - never local. + await helper.setToUser(userId, API_KEY_CLIENT_ID_DISK, existingApiKeyClientId); + } delete account.profile.apiKeyClientId; updatedAccount = true; } @@ -110,7 +119,10 @@ export class TokenServiceStateProviderMigrator extends Migrator<37, 38> { // Migrate API key client secret const existingApiKeyClientSecret = account?.keys?.apiKeyClientSecret; if (existingApiKeyClientSecret != null) { - await helper.setToUser(userId, API_KEY_CLIENT_SECRET_DISK, existingApiKeyClientSecret); + if (helper.type !== "web-disk-local") { + // only migrate client secret to session storage - never local. + await helper.setToUser(userId, API_KEY_CLIENT_SECRET_DISK, existingApiKeyClientSecret); + } delete account.keys.apiKeyClientSecret; updatedAccount = true; } diff --git a/libs/common/src/state-migrations/migrations/49-move-account-server-configs.spec.ts b/libs/common/src/state-migrations/migrations/49-move-account-server-configs.spec.ts new file mode 100644 index 00000000000..4533a754b6a --- /dev/null +++ b/libs/common/src/state-migrations/migrations/49-move-account-server-configs.spec.ts @@ -0,0 +1,112 @@ +import { runMigrator } from "../migration-helper.spec"; + +import { AccountServerConfigMigrator } from "./49-move-account-server-configs"; + +describe("AccountServerConfigMigrator", () => { + const migrator = new AccountServerConfigMigrator(48, 49); + + describe("all data", () => { + function toMigrate() { + return { + authenticatedAccounts: ["user1", "user2"], + user1: { + settings: { + serverConfig: { + config: "user1 server config", + }, + }, + }, + user2: { + settings: { + serverConfig: { + config: "user2 server config", + }, + }, + }, + }; + } + + function migrated() { + return { + authenticatedAccounts: ["user1", "user2"], + + user1: { + settings: {}, + }, + user2: { + settings: {}, + }, + user_user1_config_serverConfig: { + config: "user1 server config", + }, + user_user2_config_serverConfig: { + config: "user2 server config", + }, + }; + } + + function rolledBack(previous: object) { + return { + ...previous, + user_user1_config_serverConfig: null as unknown, + user_user2_config_serverConfig: null as unknown, + }; + } + + it("migrates", async () => { + const output = await runMigrator(migrator, toMigrate(), "migrate"); + expect(output).toEqual(migrated()); + }); + + it("rolls back", async () => { + const output = await runMigrator(migrator, migrated(), "rollback"); + expect(output).toEqual(rolledBack(toMigrate())); + }); + }); + + describe("missing parts", () => { + function toMigrate() { + return { + authenticatedAccounts: ["user1", "user2"], + user1: { + settings: { + serverConfig: { + config: "user1 server config", + }, + }, + }, + user2: null as unknown, + }; + } + + function migrated() { + return { + authenticatedAccounts: ["user1", "user2"], + user1: { + settings: {}, + }, + user2: null as unknown, + user_user1_config_serverConfig: { + config: "user1 server config", + }, + }; + } + + function rollback(previous: object) { + return { + ...previous, + user_user1_config_serverConfig: null as unknown, + }; + } + + it("migrates", async () => { + const output = await runMigrator(migrator, toMigrate(), "migrate"); + expect(output).toEqual(migrated()); + }); + + it("rolls back", async () => { + const output = await runMigrator(migrator, migrated(), "rollback"); + expect(output).toEqual(rollback(toMigrate())); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/49-move-account-server-configs.ts b/libs/common/src/state-migrations/migrations/49-move-account-server-configs.ts new file mode 100644 index 00000000000..8cc25a322dd --- /dev/null +++ b/libs/common/src/state-migrations/migrations/49-move-account-server-configs.ts @@ -0,0 +1,51 @@ +import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper"; +import { Migrator } from "../migrator"; + +const CONFIG_DISK: StateDefinitionLike = { name: "config" }; +export const USER_SERVER_CONFIG: KeyDefinitionLike = { + stateDefinition: CONFIG_DISK, + key: "serverConfig", +}; + +// Note: no need to migrate global configs, they don't currently exist + +type ExpectedAccountType = { + settings?: { + serverConfig?: unknown; + }; +}; + +export class AccountServerConfigMigrator extends Migrator<48, 49> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { + if (account?.settings?.serverConfig != null) { + await helper.setToUser(userId, USER_SERVER_CONFIG, account.settings.serverConfig); + delete account.settings.serverConfig; + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise { + const serverConfig = await helper.getFromUser(userId, USER_SERVER_CONFIG); + + if (serverConfig) { + account ??= {}; + account.settings ??= {}; + + account.settings.serverConfig = serverConfig; + await helper.setToUser(userId, USER_SERVER_CONFIG, null); + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts new file mode 100644 index 00000000000..2b960808215 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.spec.ts @@ -0,0 +1,174 @@ +import { MockProxy } from "jest-mock-extended"; + +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { KeyConnectorMigrator } from "./50-move-key-connector-to-state-provider"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"], + FirstAccount: { + profile: { + usesKeyConnector: true, + convertAccountToKeyConnector: false, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + SecondAccount: { + profile: { + usesKeyConnector: true, + convertAccountToKeyConnector: true, + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + user_FirstAccount_keyConnector_usesKeyConnector: true, + user_FirstAccount_keyConnector_convertAccountToKeyConnector: false, + user_SecondAccount_keyConnector_usesKeyConnector: true, + user_SecondAccount_keyConnector_convertAccountToKeyConnector: true, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"], + FirstAccount: { + profile: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + SecondAccount: { + profile: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +const usesKeyConnectorKeyDefinition: KeyDefinitionLike = { + key: "usesKeyConnector", + stateDefinition: { + name: "keyConnector", + }, +}; + +const convertAccountToKeyConnectorKeyDefinition: KeyDefinitionLike = { + key: "convertAccountToKeyConnector", + stateDefinition: { + name: "keyConnector", + }, +}; + +describe("KeyConnectorMigrator", () => { + let helper: MockProxy; + let sut: KeyConnectorMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 50); + sut = new KeyConnectorMigrator(49, 50); + }); + + it("should remove usesKeyConnector and convertAccountToKeyConnector from Profile", async () => { + await sut.migrate(helper); + + // Set is called 2 times even though there are 3 accounts. Since the target properties don't exist in ThirdAccount, they are not set. + expect(helper.set).toHaveBeenCalledTimes(2); + expect(helper.set).toHaveBeenCalledWith("FirstAccount", { + profile: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + usesKeyConnectorKeyDefinition, + true, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + convertAccountToKeyConnectorKeyDefinition, + false, + ); + expect(helper.set).toHaveBeenCalledWith("SecondAccount", { + profile: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + usesKeyConnectorKeyDefinition, + true, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + convertAccountToKeyConnectorKeyDefinition, + true, + ); + expect(helper.setToUser).not.toHaveBeenCalledWith("ThirdAccount"); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 50); + sut = new KeyConnectorMigrator(49, 50); + }); + + it("should null out new usesKeyConnector global value", async () => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledTimes(4); + expect(helper.set).toHaveBeenCalledTimes(2); + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + usesKeyConnectorKeyDefinition, + null, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + convertAccountToKeyConnectorKeyDefinition, + null, + ); + expect(helper.set).toHaveBeenCalledWith("FirstAccount", { + profile: { + usesKeyConnector: true, + convertAccountToKeyConnector: false, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + usesKeyConnectorKeyDefinition, + null, + ); + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + convertAccountToKeyConnectorKeyDefinition, + null, + ); + expect(helper.set).toHaveBeenCalledWith("SecondAccount", { + profile: { + usesKeyConnector: true, + convertAccountToKeyConnector: true, + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + expect(helper.setToUser).not.toHaveBeenCalledWith("ThirdAccount"); + expect(helper.set).not.toHaveBeenCalledWith("ThirdAccount"); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts b/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts new file mode 100644 index 00000000000..0deb7d5e2c0 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/50-move-key-connector-to-state-provider.ts @@ -0,0 +1,78 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountType = { + profile?: { + usesKeyConnector?: boolean; + convertAccountToKeyConnector?: boolean; + }; +}; + +const usesKeyConnectorKeyDefinition: KeyDefinitionLike = { + key: "usesKeyConnector", + stateDefinition: { + name: "keyConnector", + }, +}; + +const convertAccountToKeyConnectorKeyDefinition: KeyDefinitionLike = { + key: "convertAccountToKeyConnector", + stateDefinition: { + name: "keyConnector", + }, +}; + +export class KeyConnectorMigrator extends Migrator<49, 50> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { + const usesKeyConnector = account?.profile?.usesKeyConnector; + const convertAccountToKeyConnector = account?.profile?.convertAccountToKeyConnector; + if (usesKeyConnector == null && convertAccountToKeyConnector == null) { + return; + } + if (usesKeyConnector != null) { + await helper.setToUser(userId, usesKeyConnectorKeyDefinition, usesKeyConnector); + delete account.profile.usesKeyConnector; + } + if (convertAccountToKeyConnector != null) { + await helper.setToUser( + userId, + convertAccountToKeyConnectorKeyDefinition, + convertAccountToKeyConnector, + ); + delete account.profile.convertAccountToKeyConnector; + } + await helper.set(userId, account); + } + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise { + const usesKeyConnector: boolean = await helper.getFromUser( + userId, + usesKeyConnectorKeyDefinition, + ); + const convertAccountToKeyConnector: boolean = await helper.getFromUser( + userId, + convertAccountToKeyConnectorKeyDefinition, + ); + if (usesKeyConnector == null && convertAccountToKeyConnector == null) { + return; + } + if (usesKeyConnector != null) { + account.profile.usesKeyConnector = usesKeyConnector; + await helper.setToUser(userId, usesKeyConnectorKeyDefinition, null); + } + if (convertAccountToKeyConnector != null) { + account.profile.convertAccountToKeyConnector = convertAccountToKeyConnector; + await helper.setToUser(userId, convertAccountToKeyConnectorKeyDefinition, null); + } + await helper.set(userId, account); + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.spec.ts b/libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.spec.ts new file mode 100644 index 00000000000..f36b5842aad --- /dev/null +++ b/libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.spec.ts @@ -0,0 +1,81 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper, runMigrator } from "../migration-helper.spec"; + +import { RememberedEmailMigrator } from "./51-move-remembered-email-to-state-providers"; + +function rollbackJSON() { + return { + global: { + extra: "data", + }, + global_loginEmail_storedEmail: "user@example.com", + }; +} + +describe("RememberedEmailMigrator", () => { + const migrator = new RememberedEmailMigrator(50, 51); + + describe("migrate", () => { + it("should migrate the rememberedEmail property from the legacy global object to a global StorageKey as 'global_loginEmail_storedEmail'", async () => { + const output = await runMigrator(migrator, { + global: { + rememberedEmail: "user@example.com", + extra: "data", // Represents a global property that should persist after migration + }, + }); + + expect(output).toEqual({ + global: { + extra: "data", + }, + global_loginEmail_storedEmail: "user@example.com", + }); + }); + + it("should remove the rememberedEmail property from the legacy global object", async () => { + const output = await runMigrator(migrator, { + global: { + rememberedEmail: "user@example.com", + }, + }); + + expect(output.global).not.toHaveProperty("rememberedEmail"); + }); + }); + + describe("rollback", () => { + let helper: MockProxy; + let sut: RememberedEmailMigrator; + + const keyDefinitionLike = { + key: "storedEmail", + stateDefinition: { + name: "loginEmail", + }, + }; + + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 51); + sut = new RememberedEmailMigrator(50, 51); + }); + + it("should null out the storedEmail global StorageKey", async () => { + await sut.rollback(helper); + + expect(helper.setToGlobal).toHaveBeenCalledTimes(1); + expect(helper.setToGlobal).toHaveBeenCalledWith(keyDefinitionLike, null); + }); + + it("should add the rememberedEmail property back to legacy global object", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + rememberedEmail: "user@example.com", + extra: "data", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.ts b/libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.ts new file mode 100644 index 00000000000..b2b08187196 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/51-move-remembered-email-to-state-providers.ts @@ -0,0 +1,46 @@ +import { KeyDefinitionLike, MigrationHelper, StateDefinitionLike } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedGlobalState = { rememberedEmail?: string }; + +const LOGIN_EMAIL_STATE: StateDefinitionLike = { name: "loginEmail" }; + +const STORED_EMAIL: KeyDefinitionLike = { + key: "storedEmail", + stateDefinition: LOGIN_EMAIL_STATE, +}; + +export class RememberedEmailMigrator extends Migrator<50, 51> { + async migrate(helper: MigrationHelper): Promise { + const legacyGlobal = await helper.get("global"); + + // Move global data + if (legacyGlobal?.rememberedEmail != null) { + await helper.setToGlobal(STORED_EMAIL, legacyGlobal.rememberedEmail); + } + + // Delete legacy global data + delete legacyGlobal?.rememberedEmail; + await helper.set("global", legacyGlobal); + } + + async rollback(helper: MigrationHelper): Promise { + let legacyGlobal = await helper.get("global"); + let updatedLegacyGlobal = false; + const globalStoredEmail = await helper.getFromGlobal(STORED_EMAIL); + + if (globalStoredEmail) { + if (!legacyGlobal) { + legacyGlobal = {}; + } + + updatedLegacyGlobal = true; + legacyGlobal.rememberedEmail = globalStoredEmail; + await helper.setToGlobal(STORED_EMAIL, null); + } + + if (updatedLegacyGlobal) { + await helper.set("global", legacyGlobal); + } + } +} diff --git a/libs/common/src/state-migrations/migrations/52-delete-installed-version.spec.ts b/libs/common/src/state-migrations/migrations/52-delete-installed-version.spec.ts new file mode 100644 index 00000000000..752f1297ff7 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/52-delete-installed-version.spec.ts @@ -0,0 +1,35 @@ +import { runMigrator } from "../migration-helper.spec"; + +import { DeleteInstalledVersion } from "./52-delete-installed-version"; + +describe("DeleteInstalledVersion", () => { + const sut = new DeleteInstalledVersion(51, 52); + + describe("migrate", () => { + it("can delete data if there", async () => { + const output = await runMigrator(sut, { + authenticatedAccounts: ["user1"], + global: { + installedVersion: "2024.1.1", + }, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user1"], + global: {}, + }); + }); + + it("will run if installed version is not there", async () => { + const output = await runMigrator(sut, { + authenticatedAccounts: ["user1"], + global: {}, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user1"], + global: {}, + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/52-delete-installed-version.ts b/libs/common/src/state-migrations/migrations/52-delete-installed-version.ts new file mode 100644 index 00000000000..7eea0e587c9 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/52-delete-installed-version.ts @@ -0,0 +1,19 @@ +import { MigrationHelper } from "../migration-helper"; +import { IRREVERSIBLE, Migrator } from "../migrator"; + +type ExpectedGlobal = { + installedVersion?: string; +}; + +export class DeleteInstalledVersion extends Migrator<51, 52> { + async migrate(helper: MigrationHelper): Promise { + const legacyGlobal = await helper.get("global"); + if (legacyGlobal?.installedVersion != null) { + delete legacyGlobal.installedVersion; + await helper.set("global", legacyGlobal); + } + } + rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } +} diff --git a/libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.spec.ts b/libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.spec.ts new file mode 100644 index 00000000000..79366a47167 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.spec.ts @@ -0,0 +1,171 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + DEVICE_KEY, + DeviceTrustCryptoServiceStateProviderMigrator, + SHOULD_TRUST_DEVICE, +} from "./53-migrate-device-trust-crypto-svc-to-state-providers"; + +// Represents data in state service pre-migration +function preMigrationJson() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user1", "user2", "user3"], + user1: { + keys: { + deviceKey: { + keyB64: "user1_deviceKey", + }, + otherStuff: "overStuff2", + }, + settings: { + trustDeviceChoiceForDecryption: true, + otherStuff: "overStuff3", + }, + otherStuff: "otherStuff4", + }, + user2: { + keys: { + // no device key + otherStuff: "otherStuff5", + }, + settings: { + // no trust device choice + otherStuff: "overStuff6", + }, + otherStuff: "otherStuff7", + }, + }; +} + +function rollbackJSON() { + return { + // use pattern user_{userId}_{stateDefinitionName}_{keyDefinitionKey} for each user + // User1 migrated data + user_user1_deviceTrust_deviceKey: { + keyB64: "user1_deviceKey", + }, + user_user1_deviceTrust_shouldTrustDevice: true, + + // User2 does not have migrated data + + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user1", "user2", "user3"], + user1: { + keys: { + otherStuff: "overStuff2", + }, + settings: { + otherStuff: "overStuff3", + }, + otherStuff: "otherStuff4", + }, + user2: { + keys: { + otherStuff: "otherStuff5", + }, + settings: { + otherStuff: "overStuff6", + }, + otherStuff: "otherStuff6", + }, + }; +} + +describe("DeviceTrustCryptoServiceStateProviderMigrator", () => { + let helper: MockProxy; + let sut: DeviceTrustCryptoServiceStateProviderMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(preMigrationJson(), 52); + sut = new DeviceTrustCryptoServiceStateProviderMigrator(52, 53); + }); + + // it should remove deviceKey and trustDeviceChoiceForDecryption from all accounts + it("should remove deviceKey and trustDeviceChoiceForDecryption from all accounts that have it", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("user1", { + keys: { + otherStuff: "overStuff2", + }, + settings: { + otherStuff: "overStuff3", + }, + otherStuff: "otherStuff4", + }); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).not.toHaveBeenCalledWith("user2", any()); + expect(helper.set).not.toHaveBeenCalledWith("user3", any()); + }); + + it("should migrate deviceKey and trustDeviceChoiceForDecryption to state providers for accounts that have the data", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user1", DEVICE_KEY, { + keyB64: "user1_deviceKey", + }); + expect(helper.setToUser).toHaveBeenCalledWith("user1", SHOULD_TRUST_DEVICE, true); + + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", DEVICE_KEY, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", SHOULD_TRUST_DEVICE, any()); + + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", DEVICE_KEY, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", SHOULD_TRUST_DEVICE, any()); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 53); + sut = new DeviceTrustCryptoServiceStateProviderMigrator(52, 53); + }); + + it("should null out newly migrated entries in state provider framework", async () => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user1", DEVICE_KEY, null); + expect(helper.setToUser).toHaveBeenCalledWith("user1", SHOULD_TRUST_DEVICE, null); + + expect(helper.setToUser).toHaveBeenCalledWith("user2", DEVICE_KEY, null); + expect(helper.setToUser).toHaveBeenCalledWith("user2", SHOULD_TRUST_DEVICE, null); + + expect(helper.setToUser).toHaveBeenCalledWith("user3", DEVICE_KEY, null); + expect(helper.setToUser).toHaveBeenCalledWith("user3", SHOULD_TRUST_DEVICE, null); + }); + + it("should add back deviceKey and trustDeviceChoiceForDecryption to all accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("user1", { + keys: { + deviceKey: { + keyB64: "user1_deviceKey", + }, + otherStuff: "overStuff2", + }, + settings: { + trustDeviceChoiceForDecryption: true, + otherStuff: "overStuff3", + }, + otherStuff: "otherStuff4", + }); + }); + + it("should not add data back if data wasn't migrated or acct doesn't exist", async () => { + await sut.rollback(helper); + + // no data to add back for user2 (acct exists but no migrated data) and user3 (no acct) + expect(helper.set).not.toHaveBeenCalledWith("user2", any()); + expect(helper.set).not.toHaveBeenCalledWith("user3", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.ts b/libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.ts new file mode 100644 index 00000000000..e19c7b3fa5a --- /dev/null +++ b/libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.ts @@ -0,0 +1,95 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +// Types to represent data as it is stored in JSON +type DeviceKeyJsonType = { + keyB64: string; +}; + +type ExpectedAccountType = { + keys?: { + deviceKey?: DeviceKeyJsonType; + }; + settings?: { + trustDeviceChoiceForDecryption?: boolean; + }; +}; + +export const DEVICE_KEY: KeyDefinitionLike = { + key: "deviceKey", // matches KeyDefinition.key in DeviceTrustCryptoService + stateDefinition: { + name: "deviceTrust", // matches StateDefinition.name in StateDefinitions + }, +}; + +export const SHOULD_TRUST_DEVICE: KeyDefinitionLike = { + key: "shouldTrustDevice", + stateDefinition: { + name: "deviceTrust", + }, +}; + +export class DeviceTrustCryptoServiceStateProviderMigrator extends Migrator<52, 53> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { + let updatedAccount = false; + + // Migrate deviceKey + const existingDeviceKey = account?.keys?.deviceKey; + + if (existingDeviceKey != null) { + // Only migrate data that exists + await helper.setToUser(userId, DEVICE_KEY, existingDeviceKey); + delete account.keys.deviceKey; + updatedAccount = true; + } + + // Migrate shouldTrustDevice + const existingShouldTrustDevice = account?.settings?.trustDeviceChoiceForDecryption; + + if (existingShouldTrustDevice != null) { + await helper.setToUser(userId, SHOULD_TRUST_DEVICE, existingShouldTrustDevice); + delete account.settings.trustDeviceChoiceForDecryption; + updatedAccount = true; + } + + if (updatedAccount) { + // Save the migrated account + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise { + // Rollback deviceKey + const migratedDeviceKey: DeviceKeyJsonType = await helper.getFromUser(userId, DEVICE_KEY); + + if (account?.keys && migratedDeviceKey != null) { + account.keys.deviceKey = migratedDeviceKey; + await helper.set(userId, account); + } + + await helper.setToUser(userId, DEVICE_KEY, null); + + // Rollback shouldTrustDevice + const migratedShouldTrustDevice = await helper.getFromUser( + userId, + SHOULD_TRUST_DEVICE, + ); + + if (account?.settings && migratedShouldTrustDevice != null) { + account.settings.trustDeviceChoiceForDecryption = migratedShouldTrustDevice; + await helper.set(userId, account); + } + + await helper.setToUser(userId, SHOULD_TRUST_DEVICE, null); + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts b/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts new file mode 100644 index 00000000000..9e73a1258a8 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.spec.ts @@ -0,0 +1,236 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { SendMigrator } from "./54-move-encrypted-sends"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2"], + "user-1": { + data: { + sends: { + encrypted: { + "2ebadc23-e101-471b-bf2d-b125015337a0": { + id: "2ebadc23-e101-471b-bf2d-b125015337a0", + accessId: "I9y6LgHhG0e_LbElAVM3oA", + deletionDate: "2024-03-07T20:35:03Z", + disabled: false, + hideEmail: false, + key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=", + name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=", + text: { + hidden: false, + text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=", + }, + type: 0, + }, + "3b31c20d-b783-4912-9170-b12501555398": { + id: "3b31c20d-b783-4912-9170-b12501555398", + accessId: "DcIxO4O3EkmRcLElAVVTmA", + deletionDate: "2024-03-07T20:42:43Z", + disabled: false, + hideEmail: false, + key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=", + name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=", + text: { + hidden: false, + text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=", + }, + type: 0, + }, + }, + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + data: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + "user_user-1_send_sends": { + "2ebadc23-e101-471b-bf2d-b125015337a0": { + id: "2ebadc23-e101-471b-bf2d-b125015337a0", + accessId: "I9y6LgHhG0e_LbElAVM3oA", + deletionDate: "2024-03-07T20:35:03Z", + disabled: false, + hideEmail: false, + key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=", + name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=", + text: { + hidden: false, + text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=", + }, + type: 0, + }, + "3b31c20d-b783-4912-9170-b12501555398": { + id: "3b31c20d-b783-4912-9170-b12501555398", + accessId: "DcIxO4O3EkmRcLElAVVTmA", + deletionDate: "2024-03-07T20:42:43Z", + disabled: false, + hideEmail: false, + key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=", + name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=", + text: { + hidden: false, + text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=", + }, + type: 0, + }, + }, + "user_user-2_send_data": null as any, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2"], + "user-1": { + data: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + data: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +describe("SendMigrator", () => { + let helper: MockProxy; + let sut: SendMigrator; + const keyDefinitionLike = { + stateDefinition: { + name: "send", + }, + key: "sends", + }; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 53); + sut = new SendMigrator(53, 54); + }); + + it("should remove encrypted sends from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("user-1", { + data: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should set encrypted sends for each account", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, { + "2ebadc23-e101-471b-bf2d-b125015337a0": { + id: "2ebadc23-e101-471b-bf2d-b125015337a0", + accessId: "I9y6LgHhG0e_LbElAVM3oA", + deletionDate: "2024-03-07T20:35:03Z", + disabled: false, + hideEmail: false, + key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=", + name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=", + text: { + hidden: false, + text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=", + }, + type: 0, + }, + "3b31c20d-b783-4912-9170-b12501555398": { + id: "3b31c20d-b783-4912-9170-b12501555398", + accessId: "DcIxO4O3EkmRcLElAVVTmA", + deletionDate: "2024-03-07T20:42:43Z", + disabled: false, + hideEmail: false, + key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=", + name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=", + text: { + hidden: false, + text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=", + }, + type: 0, + }, + }); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 54); + sut = new SendMigrator(53, 54); + }); + + it.each(["user-1", "user-2"])("should null out new values", async (userId) => { + await sut.rollback(helper); + expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null); + }); + + it("should add encrypted send values back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalled(); + expect(helper.set).toHaveBeenCalledWith("user-1", { + data: { + sends: { + encrypted: { + "2ebadc23-e101-471b-bf2d-b125015337a0": { + id: "2ebadc23-e101-471b-bf2d-b125015337a0", + accessId: "I9y6LgHhG0e_LbElAVM3oA", + deletionDate: "2024-03-07T20:35:03Z", + disabled: false, + hideEmail: false, + key: "2.sR07sf4f18Rw6YQH9R/fPw==|DlLIYdTlFBktHVEJixqrOZmW/dTDGmZ+9iVftYkRh4s=|2mXH2fKgtItEMi8rcP1ykkVwRbxztw5MGboBwRl/kKM=", + name: "2.A0wIvDbyzuh6AjgFtv2gqQ==|D0FymzfCdYJQcAk5MARfjg==|2g52y7e/33A7Bafaaoy3Yvae7vxbIxoABZdZeoZuyg4=", + text: { + hidden: false, + text: "2.MkcPiJUnNfpcyETsoH3b8g==|/oHZ5g6pmcerXAJidP9sXg==|JDhd1Blsxm/ubp2AAggHZr6gZhyW4UYwZkF5rxlO6X0=", + }, + type: 0, + }, + "3b31c20d-b783-4912-9170-b12501555398": { + id: "3b31c20d-b783-4912-9170-b12501555398", + accessId: "DcIxO4O3EkmRcLElAVVTmA", + deletionDate: "2024-03-07T20:42:43Z", + disabled: false, + hideEmail: false, + key: "2.366XwLCi7RJnXuAvpsEVNw==|XfLoSsdOIYsHfcSMmv+7VJY97bKfS3fjpbq3ez+KCdk=|iTJxf4Pc3ub6hTFXGeU8NpUV3KxnuxzaHuNoFo/I6Vs=", + name: "2.uJ2FoouFJr/SR9gv3jYY/Q==|ksVre4/YqwY/XOtPyIfIJw==|/LVT842LJgyAchl7NffogXkrmCFwOEHX9NFd0zgLqKo=", + text: { + hidden: false, + text: "2.zBeOzMKtjnP5YI5lJWQTWA==|vxrGt4GKtydhrqaW35b/jw==|36Jtg172awn9YsgfzNs4pJ/OpA59NBnUkLNt6lg7Zw8=", + }, + type: 0, + }, + }, + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should not try to restore values to missing accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith("user-3", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.ts b/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.ts new file mode 100644 index 00000000000..7f60d18ffe9 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/54-move-encrypted-sends.ts @@ -0,0 +1,67 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +export enum SendType { + Text = 0, + File = 1, +} + +type SendData = { + id: string; + accessId: string; +}; + +type ExpectedSendState = { + data?: { + sends?: { + encrypted?: Record; + }; + }; +}; + +const ENCRYPTED_SENDS: KeyDefinitionLike = { + stateDefinition: { + name: "send", + }, + key: "sends", +}; + +/** + * Only encrypted sends are stored on disk. Only the encrypted items need to be + * migrated from the previous sends state data. + */ +export class SendMigrator extends Migrator<53, 54> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function migrateAccount(userId: string, account: ExpectedSendState): Promise { + const value = account?.data?.sends?.encrypted; + if (value != null) { + await helper.setToUser(userId, ENCRYPTED_SENDS, value); + delete account.data.sends; + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function rollbackAccount(userId: string, account: ExpectedSendState): Promise { + const value = await helper.getFromUser(userId, ENCRYPTED_SENDS); + if (account) { + account.data = Object.assign(account.data ?? {}, { + sends: { + encrypted: value, + }, + }); + + await helper.set(userId, account); + } + await helper.setToUser(userId, ENCRYPTED_SENDS, null); + } + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/common/src/state-migrations/migrator.spec.ts b/libs/common/src/state-migrations/migrator.spec.ts index 3abaa277273..d1189c25ea7 100644 --- a/libs/common/src/state-migrations/migrator.spec.ts +++ b/libs/common/src/state-migrations/migrator.spec.ts @@ -26,7 +26,7 @@ describe("migrator default methods", () => { beforeEach(() => { storage = mock(); logService = mock(); - helper = new MigrationHelper(0, storage, logService); + helper = new MigrationHelper(0, storage, logService, "general"); sut = new TestMigrator(0, 1); }); diff --git a/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts new file mode 100644 index 00000000000..edda0dcb2ba --- /dev/null +++ b/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts @@ -0,0 +1,47 @@ +import { Observable } from "rxjs"; + +import { UserId } from "../../../types/guid"; +import { GeneratedCredential, GeneratorCategory } from "../history"; + +/** Tracks the history of password generations. + * Each user gets their own store. + */ +export abstract class GeneratorHistoryService { + /** Tracks a new credential. When an item with the same `credential` value + * is found, this method does nothing. When the total number of items exceeds + * {@link HistoryServiceOptions.maxTotal}, then the oldest items exceeding the total + * are deleted. + * @param userId identifies the user storing the credential. + * @param credential stored by the history service. + * @param date when the credential was generated. If this is omitted, then the generator + * uses the date the credential was added to the store instead. + * @returns a promise that completes with the added credential. If the credential + * wasn't added, then the promise completes with `null`. + * @remarks this service is not suitable for use with vault items/ciphers. It models only + * a history of an individually generated credential, while a vault item's history + * may contain several credentials that are better modelled as atomic versions of the + * vault item itself. + */ + track: ( + userId: UserId, + credential: string, + category: GeneratorCategory, + date?: Date, + ) => Promise; + + /** Removes a matching credential from the history service. + * @param userId identifies the user taking the credential. + * @param credential to match in the history service. + * @returns A promise that completes with the credential read. If the credential wasn't found, + * the promise completes with null. + * @remarks this can be used to extract an entry when a credential is stored in the vault. + */ + take: (userId: UserId, credential: string) => Promise; + + /** Lists all credentials for a user. + * @param userId identifies the user listing the credential. + * @remarks This field is eventually consistent with `track` and `take` operations. + * It is not guaranteed to immediately reflect those changes. + */ + credentials$: (userId: UserId) => Observable; +} diff --git a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts index f11c1d73009..eda02f7cdcb 100644 --- a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts @@ -1,3 +1,5 @@ +import { Observable } from "rxjs"; + import { PolicyType } from "../../../admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models // implement ADR-0002 @@ -21,13 +23,16 @@ export abstract class GeneratorStrategy { /** Length of time in milliseconds to cache the evaluator */ cache_ms: number; - /** Creates an evaluator from a generator policy. + /** Operator function that converts a policy collection observable to a single + * policy evaluator observable. * @param policy The policy being evaluated. * @returns the policy evaluator. If `policy` is is `null` or `undefined`, * then the evaluator defaults to the application's limits. * @throws when the policy's type does not match the generator's policy type. */ - evaluator: (policy: AdminPolicy) => PolicyEvaluator; + toEvaluator: () => ( + source: Observable, + ) => Observable>; /** Generates credentials from the given options. * @param options The options used to generate the credentials. diff --git a/libs/common/src/tools/generator/default-generator.service.spec.ts b/libs/common/src/tools/generator/default-generator.service.spec.ts index 84b8ff45303..53a46c4963e 100644 --- a/libs/common/src/tools/generator/default-generator.service.spec.ts +++ b/libs/common/src/tools/generator/default-generator.service.spec.ts @@ -4,7 +4,7 @@ */ import { mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; +import { BehaviorSubject, firstValueFrom, map, pipe } from "rxjs"; import { FakeSingleUserState, awaitAsync } from "../../../spec"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; @@ -20,12 +20,12 @@ import { PasswordGenerationOptions } from "./password"; import { DefaultGeneratorService } from "."; -function mockPolicyService(config?: { state?: BehaviorSubject }) { +function mockPolicyService(config?: { state?: BehaviorSubject }) { const service = mock(); // FIXME: swap out the mock return value when `getAll$` becomes available - const stateValue = config?.state ?? new BehaviorSubject(null); - service.get$.mockReturnValue(stateValue); + const stateValue = config?.state ?? new BehaviorSubject([null]); + service.getAll$.mockReturnValue(stateValue); // const stateValue = config?.state ?? new BehaviorSubject(null); // service.getAll$.mockReturnValue(stateValue); @@ -46,7 +46,9 @@ function mockGeneratorStrategy(config?: { // the value from `config`. durableState: jest.fn(() => durableState), policy: config?.policy ?? PolicyType.DisableSend, - evaluator: jest.fn(() => config?.evaluator ?? mock>()), + toEvaluator: jest.fn(() => + pipe(map(() => config?.evaluator ?? mock>())), + ), }); return strategy; @@ -94,9 +96,7 @@ describe("Password generator service", () => { await firstValueFrom(service.evaluator$(SomeUser)); - // FIXME: swap out the expect when `getAll$` becomes available - expect(policy.get$).toHaveBeenCalledWith(PolicyType.PasswordGenerator); - //expect(policy.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); + expect(policy.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); }); it("should map the policy using the generation strategy", async () => { @@ -112,21 +112,22 @@ describe("Password generator service", () => { it("should update the evaluator when the password generator policy changes", async () => { // set up dependencies - const state = new BehaviorSubject(null); + const state = new BehaviorSubject([null]); const policy = mockPolicyService({ state }); const strategy = mockGeneratorStrategy(); const service = new DefaultGeneratorService(strategy, policy); - // model responses for the observable update + // model responses for the observable update. The map is called multiple times, + // and the array shift ensures reference equality is maintained. const firstEvaluator = mock>(); - strategy.evaluator.mockReturnValueOnce(firstEvaluator); const secondEvaluator = mock>(); - strategy.evaluator.mockReturnValueOnce(secondEvaluator); + const evaluators = [firstEvaluator, secondEvaluator]; + strategy.toEvaluator.mockReturnValueOnce(pipe(map(() => evaluators.shift()))); // act const evaluator$ = service.evaluator$(SomeUser); const firstResult = await firstValueFrom(evaluator$); - state.next(null); + state.next([null]); const secondResult = await firstValueFrom(evaluator$); // assert @@ -142,9 +143,7 @@ describe("Password generator service", () => { await firstValueFrom(service.evaluator$(SomeUser)); await firstValueFrom(service.evaluator$(SomeUser)); - // FIXME: swap out the expect when `getAll$` becomes available - expect(policy.get$).toHaveBeenCalledTimes(1); - //expect(policy.getAll$).toHaveBeenCalledTimes(1); + expect(policy.getAll$).toHaveBeenCalledTimes(1); }); it("should cache the password generator policy for each user", async () => { @@ -155,9 +154,8 @@ describe("Password generator service", () => { await firstValueFrom(service.evaluator$(SomeUser)); await firstValueFrom(service.evaluator$(AnotherUser)); - // FIXME: enable this test when `getAll$` becomes available - // expect(policy.getAll$).toHaveBeenNthCalledWith(1, PolicyType.PasswordGenerator, SomeUser); - // expect(policy.getAll$).toHaveBeenNthCalledWith(2, PolicyType.PasswordGenerator, AnotherUser); + expect(policy.getAll$).toHaveBeenNthCalledWith(1, PolicyType.PasswordGenerator, SomeUser); + expect(policy.getAll$).toHaveBeenNthCalledWith(2, PolicyType.PasswordGenerator, AnotherUser); }); }); diff --git a/libs/common/src/tools/generator/default-generator.service.ts b/libs/common/src/tools/generator/default-generator.service.ts index 9c884ccefdc..34aacee695c 100644 --- a/libs/common/src/tools/generator/default-generator.service.ts +++ b/libs/common/src/tools/generator/default-generator.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, map, share, timer, ReplaySubject, Observable } from "rxjs"; +import { firstValueFrom, share, timer, ReplaySubject, Observable } from "rxjs"; // FIXME: use index.ts imports once policy abstractions and models // implement ADR-0002 @@ -44,14 +44,12 @@ export class DefaultGeneratorService implements GeneratorServic } private createEvaluator(userId: UserId) { - // FIXME: when it becomes possible to get a user-specific policy observable - // (`getAll$`) update this code to call it instead of `get$`. - const policies$ = this.policy.get$(this.strategy.policy); + const evaluator$ = this.policy.getAll$(this.strategy.policy, userId).pipe( + // create the evaluator from the policies + this.strategy.toEvaluator(), - // cache evaluator in a replay subject to amortize creation cost - // and reduce GC pressure. - const evaluator$ = policies$.pipe( - map((policy) => this.strategy.evaluator(policy)), + // cache evaluator in a replay subject to amortize creation cost + // and reduce GC pressure. share({ connector: () => new ReplaySubject(1), resetOnRefCountZero: () => timer(this.strategy.cache_ms), diff --git a/libs/common/src/tools/generator/history/generated-credential.spec.ts b/libs/common/src/tools/generator/history/generated-credential.spec.ts new file mode 100644 index 00000000000..170030bad17 --- /dev/null +++ b/libs/common/src/tools/generator/history/generated-credential.spec.ts @@ -0,0 +1,58 @@ +import { GeneratorCategory, GeneratedCredential } from "./"; + +describe("GeneratedCredential", () => { + describe("constructor", () => { + it("assigns credential", () => { + const result = new GeneratedCredential("example", "passphrase", new Date(100)); + + expect(result.credential).toEqual("example"); + }); + + it("assigns category", () => { + const result = new GeneratedCredential("example", "passphrase", new Date(100)); + + expect(result.category).toEqual("passphrase"); + }); + + it("passes through date parameters", () => { + const result = new GeneratedCredential("example", "password", new Date(100)); + + expect(result.generationDate).toEqual(new Date(100)); + }); + + it("converts numeric dates to Dates", () => { + const result = new GeneratedCredential("example", "password", 100); + + expect(result.generationDate).toEqual(new Date(100)); + }); + }); + + it("toJSON converts from a credential into a JSON object", () => { + const credential = new GeneratedCredential("example", "password", new Date(100)); + + const result = credential.toJSON(); + + expect(result).toEqual({ + credential: "example", + category: "password" as GeneratorCategory, + generationDate: 100, + }); + }); + + it("fromJSON converts Json objects into credentials", () => { + const jsonValue = { + credential: "example", + category: "password" as GeneratorCategory, + generationDate: 100, + }; + + const result = GeneratedCredential.fromJSON(jsonValue); + + expect(result).toBeInstanceOf(GeneratedCredential); + expect(result).toEqual({ + credential: "example", + category: "password", + generationDate: new Date(100), + }); + }); +}); diff --git a/libs/common/src/tools/generator/history/generated-credential.ts b/libs/common/src/tools/generator/history/generated-credential.ts new file mode 100644 index 00000000000..59a9623bf7e --- /dev/null +++ b/libs/common/src/tools/generator/history/generated-credential.ts @@ -0,0 +1,47 @@ +import { Jsonify } from "type-fest"; + +import { GeneratorCategory } from "./options"; + +/** A credential generation result */ +export class GeneratedCredential { + /** + * Instantiates a generated credential + * @param credential The value of the generated credential (e.g. a password) + * @param category The kind of credential + * @param generationDate The date that the credential was generated. + * Numeric values should are interpreted using {@link Date.valueOf} + * semantics. + */ + constructor( + readonly credential: string, + readonly category: GeneratorCategory, + generationDate: Date | number, + ) { + if (typeof generationDate === "number") { + this.generationDate = new Date(generationDate); + } else { + this.generationDate = generationDate; + } + } + + /** The date that the credential was generated */ + generationDate: Date; + + /** Constructs a credential from its `toJSON` representation */ + static fromJSON(jsonValue: Jsonify) { + return new GeneratedCredential( + jsonValue.credential, + jsonValue.category, + jsonValue.generationDate, + ); + } + + /** Serializes a credential to a JSON-compatible object */ + toJSON() { + return { + credential: this.credential, + category: this.category, + generationDate: this.generationDate.valueOf(), + }; + } +} diff --git a/libs/common/src/tools/generator/history/index.ts b/libs/common/src/tools/generator/history/index.ts new file mode 100644 index 00000000000..1952a849af2 --- /dev/null +++ b/libs/common/src/tools/generator/history/index.ts @@ -0,0 +1,2 @@ +export { GeneratorCategory } from "./options"; +export { GeneratedCredential } from "./generated-credential"; diff --git a/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts b/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts new file mode 100644 index 00000000000..57dde51fc13 --- /dev/null +++ b/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts @@ -0,0 +1,198 @@ +import { mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { FakeStateProvider, awaitAsync, mockAccountServiceWith } from "../../../../spec"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../../types/csprng"; +import { UserId } from "../../../types/guid"; +import { UserKey } from "../../../types/key"; + +import { LocalGeneratorHistoryService } from "./local-generator-history.service"; + +const SomeUser = "SomeUser" as UserId; +const AnotherUser = "AnotherUser" as UserId; + +describe("LocalGeneratorHistoryService", () => { + const encryptService = mock(); + const keyService = mock(); + const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey; + + beforeEach(() => { + encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); + encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString)); + keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey)); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("credential$", () => { + it("returns an empty list when no credentials are stored", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + const result = await firstValueFrom(history.credentials$(SomeUser)); + + expect(result).toEqual([]); + }); + }); + + describe("track", () => { + it("stores a password", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "example", "password"); + await awaitAsync(); + const [result] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(result).toMatchObject({ credential: "example", category: "password" }); + }); + + it("stores a passphrase", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "example", "passphrase"); + await awaitAsync(); + const [result] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(result).toMatchObject({ credential: "example", category: "passphrase" }); + }); + + it("stores a specific date when one is provided", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "example", "password", new Date(100)); + await awaitAsync(); + const [result] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(result).toEqual({ + credential: "example", + category: "password", + generationDate: new Date(100), + }); + }); + + it("skips storing a credential when it's already stored (ignores category)", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", "passphrase"); + await awaitAsync(); + const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(firstResult).toMatchObject({ credential: "example", category: "password" }); + expect(secondResult).toBeUndefined(); + }); + + it("stores multiple credentials when the credential value is different", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "secondResult", "password"); + await history.track(SomeUser, "firstResult", "password"); + await awaitAsync(); + const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(firstResult).toMatchObject({ credential: "firstResult", category: "password" }); + expect(secondResult).toMatchObject({ credential: "secondResult", category: "password" }); + }); + + it("removes history items exceeding maxTotal configuration", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, { + maxTotal: 1, + }); + + await history.track(SomeUser, "removed result", "password"); + await history.track(SomeUser, "example", "password"); + await awaitAsync(); + const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(firstResult).toMatchObject({ credential: "example", category: "password" }); + expect(secondResult).toBeUndefined(); + }); + + it("stores history items in per-user collections", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, { + maxTotal: 1, + }); + + await history.track(SomeUser, "some user example", "password"); + await history.track(AnotherUser, "another user example", "password"); + await awaitAsync(); + const [someFirstResult, someSecondResult] = await firstValueFrom( + history.credentials$(SomeUser), + ); + const [anotherFirstResult, anotherSecondResult] = await firstValueFrom( + history.credentials$(AnotherUser), + ); + + expect(someFirstResult).toMatchObject({ + credential: "some user example", + category: "password", + }); + expect(someSecondResult).toBeUndefined(); + expect(anotherFirstResult).toMatchObject({ + credential: "another user example", + category: "password", + }); + expect(anotherSecondResult).toBeUndefined(); + }); + }); + + describe("take", () => { + it("returns null when there are no credentials stored", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + const result = await history.take(SomeUser, "example"); + + expect(result).toBeNull(); + }); + + it("returns null when the credential wasn't found", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + await history.track(SomeUser, "example", "password"); + + const result = await history.take(SomeUser, "not found"); + + expect(result).toBeNull(); + }); + + it("returns a matching credential", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + await history.track(SomeUser, "example", "password"); + + const result = await history.take(SomeUser, "example"); + + expect(result).toMatchObject({ + credential: "example", + category: "password", + }); + }); + + it("removes a matching credential", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + await history.track(SomeUser, "example", "password"); + + await history.take(SomeUser, "example"); + await awaitAsync(); + const results = await firstValueFrom(history.credentials$(SomeUser)); + + expect(results).toEqual([]); + }); + }); +}); diff --git a/libs/common/src/tools/generator/history/local-generator-history.service.ts b/libs/common/src/tools/generator/history/local-generator-history.service.ts new file mode 100644 index 00000000000..3a65890c50d --- /dev/null +++ b/libs/common/src/tools/generator/history/local-generator-history.service.ts @@ -0,0 +1,116 @@ +import { map } from "rxjs"; + +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { SingleUserState, StateProvider } from "../../../platform/state"; +import { UserId } from "../../../types/guid"; +import { GeneratorHistoryService } from "../abstractions/generator-history.abstraction"; +import { GENERATOR_HISTORY } from "../key-definitions"; +import { PaddedDataPacker } from "../state/padded-data-packer"; +import { SecretState } from "../state/secret-state"; +import { UserKeyEncryptor } from "../state/user-key-encryptor"; + +import { GeneratedCredential } from "./generated-credential"; +import { GeneratorCategory, HistoryServiceOptions } from "./options"; + +const OPTIONS_FRAME_SIZE = 2048; + +/** Tracks the history of password generations local to a device. + * {@link GeneratorHistoryService} + */ +export class LocalGeneratorHistoryService extends GeneratorHistoryService { + constructor( + private readonly encryptService: EncryptService, + private readonly keyService: CryptoService, + private readonly stateProvider: StateProvider, + private readonly options: HistoryServiceOptions = { maxTotal: 100 }, + ) { + super(); + } + + private _credentialStates = new Map>(); + + /** {@link GeneratorHistoryService.track} */ + track = async (userId: UserId, credential: string, category: GeneratorCategory, date?: Date) => { + const state = this.getCredentialState(userId); + let result: GeneratedCredential = null; + + await state.update( + (credentials) => { + credentials = credentials ?? []; + + // add the result + result = new GeneratedCredential(credential, category, date ?? Date.now()); + credentials.unshift(result); + + // trim history + const removeAt = Math.max(0, this.options.maxTotal); + credentials.splice(removeAt, Infinity); + + return credentials; + }, + { + shouldUpdate: (credentials) => + credentials?.some((f) => f.credential !== credential) ?? true, + }, + ); + + return result; + }; + + /** {@link GeneratorHistoryService.take} */ + take = async (userId: UserId, credential: string) => { + const state = this.getCredentialState(userId); + let credentialIndex: number; + let result: GeneratedCredential = null; + + await state.update( + (credentials) => { + credentials = credentials ?? []; + + [result] = credentials.splice(credentialIndex, 1); + return credentials; + }, + { + shouldUpdate: (credentials) => { + credentialIndex = credentials?.findIndex((f) => f.credential === credential) ?? -1; + return credentialIndex >= 0; + }, + }, + ); + + return result; + }; + + /** {@link GeneratorHistoryService.credentials$} */ + credentials$ = (userId: UserId) => { + return this.getCredentialState(userId).state$.pipe(map((credentials) => credentials ?? [])); + }; + + private getCredentialState(userId: UserId) { + let state = this._credentialStates.get(userId); + + if (!state) { + state = this.createSecretState(userId); + this._credentialStates.set(userId, state); + } + + return state; + } + + private createSecretState(userId: UserId) { + // construct the encryptor + const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); + const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); + + const state = SecretState.from< + GeneratedCredential[], + number, + GeneratedCredential, + Record, + GeneratedCredential + >(userId, GENERATOR_HISTORY, this.stateProvider, encryptor); + + return state; + } +} diff --git a/libs/common/src/tools/generator/history/options.ts b/libs/common/src/tools/generator/history/options.ts new file mode 100644 index 00000000000..53716ec33ab --- /dev/null +++ b/libs/common/src/tools/generator/history/options.ts @@ -0,0 +1,10 @@ +/** Kinds of credentials that can be stored by the history service */ +export type GeneratorCategory = "password" | "passphrase"; + +/** Configuration options for the history service */ +export type HistoryServiceOptions = { + /** Total number of records retained across all types. + * @remarks Setting this to 0 or less disables history completely. + * */ + maxTotal: number; +}; diff --git a/libs/common/src/tools/generator/key-definition.spec.ts b/libs/common/src/tools/generator/key-definition.spec.ts index 735377a5ba2..f21767e77e8 100644 --- a/libs/common/src/tools/generator/key-definition.spec.ts +++ b/libs/common/src/tools/generator/key-definition.spec.ts @@ -1,5 +1,4 @@ import { - ENCRYPTED_HISTORY, EFF_USERNAME_SETTINGS, CATCHALL_SETTINGS, SUBADDRESS_SETTINGS, @@ -101,12 +100,4 @@ describe("Key definitions", () => { expect(result).toBe(value); }); }); - - describe("ENCRYPTED_HISTORY", () => { - it("should pass through deserialization", () => { - const value = {}; - const result = ENCRYPTED_HISTORY.deserializer(value as any); - expect(result).toBe(value); - }); - }); }); diff --git a/libs/common/src/tools/generator/key-definitions.ts b/libs/common/src/tools/generator/key-definitions.ts index bb7c4e8a086..d51af70f2e2 100644 --- a/libs/common/src/tools/generator/key-definitions.ts +++ b/libs/common/src/tools/generator/key-definitions.ts @@ -1,8 +1,10 @@ import { GENERATOR_DISK, KeyDefinition } from "../../platform/state"; +import { GeneratedCredential } from "./history/generated-credential"; import { PassphraseGenerationOptions } from "./passphrase/passphrase-generation-options"; -import { GeneratedPasswordHistory } from "./password/generated-password-history"; import { PasswordGenerationOptions } from "./password/password-generation-options"; +import { SecretClassifier } from "./state/secret-classifier"; +import { SecretKeyDefinition } from "./state/secret-key-definition"; import { CatchallGenerationOptions } from "./username/catchall-generator-options"; import { EffUsernameGenerationOptions } from "./username/eff-username-generator-options"; import { @@ -107,10 +109,11 @@ export const SIMPLE_LOGIN_FORWARDER = new KeyDefinition( ); /** encrypted password generation history */ -export const ENCRYPTED_HISTORY = new KeyDefinition( +export const GENERATOR_HISTORY = SecretKeyDefinition.array( GENERATOR_DISK, - "passwordGeneratorHistory", + "localGeneratorHistory", + SecretClassifier.allSecret(), { - deserializer: (value) => value, + deserializer: GeneratedCredential.fromJSON, }, ); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.spec.ts new file mode 100644 index 00000000000..991b2ae3024 --- /dev/null +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.spec.ts @@ -0,0 +1,51 @@ +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; +import { PolicyId } from "../../../types/guid"; + +import { DisabledPassphraseGeneratorPolicy, leastPrivilege } from "./passphrase-generator-policy"; + +function createPolicy( + data: any, + type: PolicyType = PolicyType.PasswordGenerator, + enabled: boolean = true, +) { + return new Policy({ + id: "id" as PolicyId, + organizationId: "organizationId", + data, + enabled, + type, + }); +} + +describe("leastPrivilege", () => { + it("should return the accumulator when the policy type does not apply", () => { + const policy = createPolicy({}, PolicyType.RequireSso); + + const result = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy); + + expect(result).toEqual(DisabledPassphraseGeneratorPolicy); + }); + + it("should return the accumulator when the policy is not enabled", () => { + const policy = createPolicy({}, PolicyType.PasswordGenerator, false); + + const result = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy); + + expect(result).toEqual(DisabledPassphraseGeneratorPolicy); + }); + + it.each([ + ["minNumberWords", 10], + ["capitalize", true], + ["includeNumber", true], + ])("should take the %p from the policy", (input, value) => { + const policy = createPolicy({ [input]: value }); + + const result = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy); + + expect(result).toEqual({ ...DisabledPassphraseGeneratorPolicy, [input]: value }); + }); +}); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts index ca54184d166..db616f16c05 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-policy.ts @@ -1,3 +1,8 @@ +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; + /** Policy options enforced during passphrase generation. */ export type PassphraseGeneratorPolicy = { minNumberWords: number; @@ -11,3 +16,24 @@ export const DisabledPassphraseGeneratorPolicy: PassphraseGeneratorPolicy = Obje capitalize: false, includeNumber: false, }); + +/** Reduces a policy into an accumulator by accepting the most restrictive + * values from each policy. + * @param acc the accumulator + * @param policy the policy to reduce + * @returns the most restrictive values between the policy and accumulator. + */ +export function leastPrivilege( + acc: PassphraseGeneratorPolicy, + policy: Policy, +): PassphraseGeneratorPolicy { + if (policy.type !== PolicyType.PasswordGenerator) { + return acc; + } + + return { + minNumberWords: Math.max(acc.minNumberWords, policy.data.minNumberWords ?? acc.minNumberWords), + capitalize: policy.data.capitalize || acc.capitalize, + includeNumber: policy.data.includeNumber || acc.includeNumber, + }; +} diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts index 031ea05f014..b7f09bd717d 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts @@ -4,6 +4,7 @@ */ import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models @@ -21,17 +22,8 @@ import { PassphraseGeneratorOptionsEvaluator, PassphraseGeneratorStrategy } from const SomeUser = "some user" as UserId; describe("Password generation strategy", () => { - describe("evaluator()", () => { - it("should throw if the policy type is incorrect", () => { - const strategy = new PassphraseGeneratorStrategy(null, null); - const policy = mock({ - type: PolicyType.DisableSend, - }); - - expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+")); - }); - - it("should map to the policy evaluator", () => { + describe("toEvaluator()", () => { + it("should map to the policy evaluator", async () => { const strategy = new PassphraseGeneratorStrategy(null, null); const policy = mock({ type: PolicyType.PasswordGenerator, @@ -42,7 +34,8 @@ describe("Password generation strategy", () => { }, }); - const evaluator = strategy.evaluator(policy); + const evaluator$ = of([policy]).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); expect(evaluator.policy).toMatchObject({ @@ -52,13 +45,18 @@ describe("Password generation strategy", () => { }); }); - it("should map `null` to a default policy evaluator", () => { - const strategy = new PassphraseGeneratorStrategy(null, null); - const evaluator = strategy.evaluator(null); + it.each([[[]], [null], [undefined]])( + "should map `%p` to a disabled password policy evaluator", + async (policies) => { + const strategy = new PassphraseGeneratorStrategy(null, null); - expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject(DisabledPassphraseGeneratorPolicy); - }); + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); + + expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); + expect(evaluator.policy).toMatchObject(DisabledPassphraseGeneratorPolicy); + }, + ); }); describe("durableState", () => { diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts index d39f54b5765..f193b2b3266 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts @@ -1,18 +1,19 @@ +import { map, pipe } from "rxjs"; + import { GeneratorStrategy } from ".."; import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { PASSPHRASE_SETTINGS } from "../key-definitions"; import { PasswordGenerationServiceAbstraction } from "../password/password-generation.service.abstraction"; +import { reduceCollection } from "../reduce-collection.operator"; import { PassphraseGenerationOptions } from "./passphrase-generation-options"; import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; import { DisabledPassphraseGeneratorPolicy, PassphraseGeneratorPolicy, + leastPrivilege, } from "./passphrase-generator-policy"; const ONE_MINUTE = 60 * 1000; @@ -23,6 +24,7 @@ export class PassphraseGeneratorStrategy { /** instantiates the password generator strategy. * @param legacy generates the passphrase + * @param stateProvider provides durable state */ constructor( private legacy: PasswordGenerationServiceAbstraction, @@ -39,26 +41,17 @@ export class PassphraseGeneratorStrategy return PolicyType.PasswordGenerator; } + /** {@link GeneratorStrategy.cache_ms} */ get cache_ms() { return ONE_MINUTE; } - /** {@link GeneratorStrategy.evaluator} */ - evaluator(policy: Policy): PassphraseGeneratorOptionsEvaluator { - if (!policy) { - return new PassphraseGeneratorOptionsEvaluator(DisabledPassphraseGeneratorPolicy); - } - - if (policy.type !== this.policy) { - const details = `Expected: ${this.policy}. Received: ${policy.type}`; - throw Error("Mismatched policy type. " + details); - } - - return new PassphraseGeneratorOptionsEvaluator({ - minNumberWords: policy.data.minNumberWords, - capitalize: policy.data.capitalize, - includeNumber: policy.data.includeNumber, - }); + /** {@link GeneratorStrategy.toEvaluator} */ + toEvaluator() { + return pipe( + reduceCollection(leastPrivilege, DisabledPassphraseGeneratorPolicy), + map((policy) => new PassphraseGeneratorOptionsEvaluator(policy)), + ); } /** {@link GeneratorStrategy.generate} */ diff --git a/libs/common/src/tools/generator/password/password-generator-policy.spec.ts b/libs/common/src/tools/generator/password/password-generator-policy.spec.ts new file mode 100644 index 00000000000..206d88741b0 --- /dev/null +++ b/libs/common/src/tools/generator/password/password-generator-policy.spec.ts @@ -0,0 +1,55 @@ +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; +import { PolicyId } from "../../../types/guid"; + +import { DisabledPasswordGeneratorPolicy, leastPrivilege } from "./password-generator-policy"; + +function createPolicy( + data: any, + type: PolicyType = PolicyType.PasswordGenerator, + enabled: boolean = true, +) { + return new Policy({ + id: "id" as PolicyId, + organizationId: "organizationId", + data, + enabled, + type, + }); +} + +describe("leastPrivilege", () => { + it("should return the accumulator when the policy type does not apply", () => { + const policy = createPolicy({}, PolicyType.RequireSso); + + const result = leastPrivilege(DisabledPasswordGeneratorPolicy, policy); + + expect(result).toEqual(DisabledPasswordGeneratorPolicy); + }); + + it("should return the accumulator when the policy is not enabled", () => { + const policy = createPolicy({}, PolicyType.PasswordGenerator, false); + + const result = leastPrivilege(DisabledPasswordGeneratorPolicy, policy); + + expect(result).toEqual(DisabledPasswordGeneratorPolicy); + }); + + it.each([ + ["minLength", 10, "minLength"], + ["useUpper", true, "useUppercase"], + ["useLower", true, "useLowercase"], + ["useNumbers", true, "useNumbers"], + ["minNumbers", 10, "numberCount"], + ["useSpecial", true, "useSpecial"], + ["minSpecial", 10, "specialCount"], + ])("should take the %p from the policy", (input, value, expected) => { + const policy = createPolicy({ [input]: value }); + + const result = leastPrivilege(DisabledPasswordGeneratorPolicy, policy); + + expect(result).toEqual({ ...DisabledPasswordGeneratorPolicy, [expected]: value }); + }); +}); diff --git a/libs/common/src/tools/generator/password/password-generator-policy.ts b/libs/common/src/tools/generator/password/password-generator-policy.ts index c28631e9dec..7de6b49788d 100644 --- a/libs/common/src/tools/generator/password/password-generator-policy.ts +++ b/libs/common/src/tools/generator/password/password-generator-policy.ts @@ -1,3 +1,8 @@ +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; + /** Policy options enforced during password generation. */ export type PasswordGeneratorPolicy = { /** The minimum length of generated passwords. @@ -48,3 +53,25 @@ export const DisabledPasswordGeneratorPolicy: PasswordGeneratorPolicy = Object.f useSpecial: false, specialCount: 0, }); + +/** Reduces a policy into an accumulator by accepting the most restrictive + * values from each policy. + * @param acc the accumulator + * @param policy the policy to reduce + * @returns the most restrictive values between the policy and accumulator. + */ +export function leastPrivilege(acc: PasswordGeneratorPolicy, policy: Policy) { + if (policy.type !== PolicyType.PasswordGenerator || !policy.enabled) { + return acc; + } + + return { + minLength: Math.max(acc.minLength, policy.data.minLength ?? acc.minLength), + useUppercase: policy.data.useUpper || acc.useUppercase, + useLowercase: policy.data.useLower || acc.useLowercase, + useNumbers: policy.data.useNumbers || acc.useNumbers, + numberCount: Math.max(acc.numberCount, policy.data.minNumbers ?? acc.numberCount), + useSpecial: policy.data.useSpecial || acc.useSpecial, + specialCount: Math.max(acc.specialCount, policy.data.minSpecial ?? acc.specialCount), + }; +} diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts index 6c213f8c543..9bfa5b5f352 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts @@ -4,6 +4,7 @@ */ import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models @@ -24,17 +25,8 @@ import { const SomeUser = "some user" as UserId; describe("Password generation strategy", () => { - describe("evaluator()", () => { - it("should throw if the policy type is incorrect", () => { - const strategy = new PasswordGeneratorStrategy(null, null); - const policy = mock({ - type: PolicyType.DisableSend, - }); - - expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+")); - }); - - it("should map to the policy evaluator", () => { + describe("toEvaluator()", () => { + it("should map to a password policy evaluator", async () => { const strategy = new PasswordGeneratorStrategy(null, null); const policy = mock({ type: PolicyType.PasswordGenerator, @@ -49,7 +41,8 @@ describe("Password generation strategy", () => { }, }); - const evaluator = strategy.evaluator(policy); + const evaluator$ = of([policy]).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); expect(evaluator.policy).toMatchObject({ @@ -63,13 +56,18 @@ describe("Password generation strategy", () => { }); }); - it("should map `null` to a default policy evaluator", () => { - const strategy = new PasswordGeneratorStrategy(null, null); - const evaluator = strategy.evaluator(null); + it.each([[[]], [null], [undefined]])( + "should map `%p` to a disabled password policy evaluator", + async (policies) => { + const strategy = new PasswordGeneratorStrategy(null, null); - expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); - expect(evaluator.policy).toMatchObject(DisabledPasswordGeneratorPolicy); - }); + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); + + expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); + expect(evaluator.policy).toMatchObject(DisabledPasswordGeneratorPolicy); + }, + ); }); describe("durableState", () => { diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.ts b/libs/common/src/tools/generator/password/password-generator-strategy.ts index 223470c5869..f8d618128b1 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.ts @@ -1,11 +1,11 @@ +import { map, pipe } from "rxjs"; + import { GeneratorStrategy } from ".."; import { PolicyType } from "../../../admin-console/enums"; -// FIXME: use index.ts imports once policy abstractions and models -// implement ADR-0002 -import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { PASSWORD_SETTINGS } from "../key-definitions"; +import { reduceCollection } from "../reduce-collection.operator"; import { PasswordGenerationOptions } from "./password-generation-options"; import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; @@ -13,6 +13,7 @@ import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options- import { DisabledPasswordGeneratorPolicy, PasswordGeneratorPolicy, + leastPrivilege, } from "./password-generator-policy"; const ONE_MINUTE = 60 * 1000; @@ -43,26 +44,12 @@ export class PasswordGeneratorStrategy return ONE_MINUTE; } - /** {@link GeneratorStrategy.evaluator} */ - evaluator(policy: Policy): PasswordGeneratorOptionsEvaluator { - if (!policy) { - return new PasswordGeneratorOptionsEvaluator(DisabledPasswordGeneratorPolicy); - } - - if (policy.type !== this.policy) { - const details = `Expected: ${this.policy}. Received: ${policy.type}`; - throw Error("Mismatched policy type. " + details); - } - - return new PasswordGeneratorOptionsEvaluator({ - minLength: policy.data.minLength, - useUppercase: policy.data.useUpper, - useLowercase: policy.data.useLower, - useNumbers: policy.data.useNumbers, - numberCount: policy.data.minNumbers, - useSpecial: policy.data.useSpecial, - specialCount: policy.data.minSpecial, - }); + /** {@link GeneratorStrategy.toEvaluator} */ + toEvaluator() { + return pipe( + reduceCollection(leastPrivilege, DisabledPasswordGeneratorPolicy), + map((policy) => new PasswordGeneratorOptionsEvaluator(policy)), + ); } /** {@link GeneratorStrategy.generate} */ diff --git a/libs/common/src/tools/generator/reduce-collection.operator.spec.ts b/libs/common/src/tools/generator/reduce-collection.operator.spec.ts new file mode 100644 index 00000000000..49648dfdf00 --- /dev/null +++ b/libs/common/src/tools/generator/reduce-collection.operator.spec.ts @@ -0,0 +1,33 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ + +import { of, firstValueFrom } from "rxjs"; + +import { reduceCollection } from "./reduce-collection.operator"; + +describe("reduceCollection", () => { + it.each([[null], [undefined], [[]]])( + "should return the default value when the collection is %p", + async (value: number[]) => { + const reduce = (acc: number, value: number) => acc + value; + const source$ = of(value); + + const result$ = source$.pipe(reduceCollection(reduce, 100)); + const result = await firstValueFrom(result$); + + expect(result).toEqual(100); + }, + ); + + it("should reduce the collection to a single value", async () => { + const reduce = (acc: number, value: number) => acc + value; + const source$ = of([1, 2, 3]); + + const result$ = source$.pipe(reduceCollection(reduce, 0)); + const result = await firstValueFrom(result$); + + expect(result).toEqual(6); + }); +}); diff --git a/libs/common/src/tools/generator/reduce-collection.operator.ts b/libs/common/src/tools/generator/reduce-collection.operator.ts new file mode 100644 index 00000000000..224595eeba2 --- /dev/null +++ b/libs/common/src/tools/generator/reduce-collection.operator.ts @@ -0,0 +1,20 @@ +import { map, OperatorFunction } from "rxjs"; + +/** + * An observable operator that reduces an emitted collection to a single object, + * returning a default if all items are ignored. + * @param reduce The reduce function to apply to the filtered collection. The + * first argument is the accumulator, and the second is the current item. The + * return value is the new accumulator. + * @param defaultValue The default value to return if the collection is empty. The + * default value is also the initial value of the accumulator. + */ +export function reduceCollection( + reduce: (acc: Accumulator, value: Item) => Accumulator, + defaultValue: Accumulator, +): OperatorFunction { + return map((values: Item[]) => { + const reduced = (values ?? []).reduce(reduce, structuredClone(defaultValue)); + return reduced; + }); +} diff --git a/libs/common/src/tools/generator/state/classified-format.ts b/libs/common/src/tools/generator/state/classified-format.ts new file mode 100644 index 00000000000..93147a0fb53 --- /dev/null +++ b/libs/common/src/tools/generator/state/classified-format.ts @@ -0,0 +1,19 @@ +import { Jsonify } from "type-fest"; + +/** Describes the structure of data stored by the SecretState's + * encrypted state. Notably, this interface ensures that `Disclosed` + * round trips through JSON serialization. It also preserves the + * Id. + */ +export type ClassifiedFormat = { + /** Identifies records. `null` when storing a `value` */ + readonly id: Id | null; + /** Serialized {@link EncString} of the secret state's + * secret-level classified data. + */ + readonly secret: string; + /** serialized representation of the secret state's + * disclosed-level classified data. + */ + readonly disclosed: Jsonify; +}; diff --git a/libs/common/src/tools/generator/state/data-packer.abstraction.ts b/libs/common/src/tools/generator/state/data-packer.abstraction.ts index cb712e0fd9b..439fbb66c8c 100644 --- a/libs/common/src/tools/generator/state/data-packer.abstraction.ts +++ b/libs/common/src/tools/generator/state/data-packer.abstraction.ts @@ -9,7 +9,7 @@ export abstract class DataPacker { * @param value is packed into the string * @returns the packed string */ - abstract pack(value: Data): string; + abstract pack(value: Jsonify): string; /** Unpacks a string produced by pack. * @param packedValue is the string to unpack diff --git a/libs/common/src/tools/generator/state/padded-data-packer.spec.ts b/libs/common/src/tools/generator/state/padded-data-packer.spec.ts index 3cf225026b4..7e1d506988a 100644 --- a/libs/common/src/tools/generator/state/padded-data-packer.spec.ts +++ b/libs/common/src/tools/generator/state/padded-data-packer.spec.ts @@ -88,14 +88,4 @@ describe("UserKeyEncryptor", () => { expect(unpacked).toEqual(input); }); - - it("should unpack a packed JSON-serializable value", () => { - const dataPacker = new PaddedDataPacker(8); - const input = { foo: new Date(100) }; - - const packed = dataPacker.pack(input); - const unpacked = dataPacker.unpack(packed); - - expect(unpacked).toEqual({ foo: "1970-01-01T00:00:00.100Z" }); - }); }); diff --git a/libs/common/src/tools/generator/state/padded-data-packer.ts b/libs/common/src/tools/generator/state/padded-data-packer.ts index b55dfa378b7..e2f5058b217 100644 --- a/libs/common/src/tools/generator/state/padded-data-packer.ts +++ b/libs/common/src/tools/generator/state/padded-data-packer.ts @@ -37,7 +37,7 @@ export class PaddedDataPacker extends DataPackerAbstraction { * with the frameSize. * @see {@link DataPackerAbstraction.unpack} */ - pack(value: Secret) { + pack(value: Jsonify) { // encode the value const json = JSON.stringify(value); const b64 = Utils.fromUtf8ToB64(json); diff --git a/libs/common/src/tools/generator/state/secret-classifier.spec.ts b/libs/common/src/tools/generator/state/secret-classifier.spec.ts index 819cd109233..41dd8dc71bf 100644 --- a/libs/common/src/tools/generator/state/secret-classifier.spec.ts +++ b/libs/common/src/tools/generator/state/secret-classifier.spec.ts @@ -77,6 +77,15 @@ describe("SecretClassifier", () => { expect(classified.disclosed).toEqual({ foo: true }); }); + it("jsonifies its outputs", () => { + const classifier = SecretClassifier.allSecret<{ foo: Date; bar: Date }>().disclose("foo"); + + const classified = classifier.classify({ foo: new Date(100), bar: new Date(100) }); + + expect(classified.disclosed).toEqual({ foo: "1970-01-01T00:00:00.100Z" }); + expect(classified.secret).toEqual({ bar: "1970-01-01T00:00:00.100Z" }); + }); + it("deletes disclosed properties from the secret member", () => { const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().disclose( "foo", @@ -106,15 +115,6 @@ describe("SecretClassifier", () => { expect(classified.disclosed).toEqual({}); }); - - it("returns its input as the secret member", () => { - const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); - const input = { foo: true }; - - const classified = classifier.classify(input); - - expect(classified.secret).toEqual(input); - }); }); describe("declassify", () => { diff --git a/libs/common/src/tools/generator/state/secret-classifier.ts b/libs/common/src/tools/generator/state/secret-classifier.ts index 232a31c686a..a26b01ac5dd 100644 --- a/libs/common/src/tools/generator/state/secret-classifier.ts +++ b/libs/common/src/tools/generator/state/secret-classifier.ts @@ -77,17 +77,19 @@ export class SecretClassifier { } /** Partitions `secret` into its disclosed properties and secret properties. - * @param secret The object to partition + * @param value The object to partition * @returns an object that classifies secrets. * The `disclosed` member is new and contains disclosed properties. - * The `secret` member aliases the secret parameter, with all - * disclosed and excluded properties deleted. + * The `secret` member is a copy of the secret parameter, including its + * prototype, with all disclosed and excluded properties deleted. */ - classify(secret: Plaintext): { disclosed: Disclosed; secret: Secret } { - const copy = { ...secret }; + classify(value: Plaintext): { disclosed: Jsonify<Disclosed>; secret: Jsonify<Secret> } { + // need to JSONify during classification because the prototype is almost guaranteed + // to be invalid when this method deletes arbitrary properties. + const secret = JSON.parse(JSON.stringify(value)) as Record<keyof Plaintext, unknown>; for (const excludedProp of this.excluded) { - delete copy[excludedProp]; + delete secret[excludedProp]; } const disclosed: Record<PropertyKey, unknown> = {}; @@ -95,13 +97,13 @@ export class SecretClassifier<Plaintext extends object, Disclosed, Secret> { // disclosedProp is known to be a subset of the keys of `Plaintext`, so these // type assertions are accurate. // FIXME: prove it to the compiler - disclosed[disclosedProp] = copy[disclosedProp as unknown as keyof Plaintext]; - delete copy[disclosedProp as unknown as keyof Plaintext]; + disclosed[disclosedProp] = secret[disclosedProp as keyof Plaintext]; + delete secret[disclosedProp as keyof Plaintext]; } return { - disclosed: disclosed as Disclosed, - secret: copy as unknown as Secret, + disclosed: disclosed as Jsonify<Disclosed>, + secret: secret as Jsonify<Secret>, }; } diff --git a/libs/common/src/tools/generator/state/secret-key-definition.spec.ts b/libs/common/src/tools/generator/state/secret-key-definition.spec.ts index 20bc1f5ee17..7352631ff6c 100644 --- a/libs/common/src/tools/generator/state/secret-key-definition.spec.ts +++ b/libs/common/src/tools/generator/state/secret-key-definition.spec.ts @@ -7,6 +7,28 @@ describe("SecretKeyDefinition", () => { const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); const options = { deserializer: (v: any) => v }; + it("toEncryptedStateKey returns a key", () => { + const expectedOptions = { + deserializer: (v: any) => v, + cleanupDelayMs: 100, + }; + const definition = SecretKeyDefinition.value( + GENERATOR_DISK, + "key", + classifier, + expectedOptions, + ); + const expectedDeserializerResult = {} as any; + + const result = definition.toEncryptedStateKey(); + const deserializerResult = result.deserializer(expectedDeserializerResult); + + expect(result.stateDefinition).toEqual(GENERATOR_DISK); + expect(result.key).toBe("key"); + expect(result.cleanupDelayMs).toBe(expectedOptions.cleanupDelayMs); + expect(deserializerResult).toBe(expectedDeserializerResult); + }); + describe("value", () => { it("returns an initialized SecretKeyDefinition", () => { const definition = SecretKeyDefinition.value(GENERATOR_DISK, "key", classifier, options); diff --git a/libs/common/src/tools/generator/state/secret-key-definition.ts b/libs/common/src/tools/generator/state/secret-key-definition.ts index eb139efbe7a..0de59be6244 100644 --- a/libs/common/src/tools/generator/state/secret-key-definition.ts +++ b/libs/common/src/tools/generator/state/secret-key-definition.ts @@ -1,6 +1,7 @@ -import { KeyDefinitionOptions } from "../../../platform/state"; +import { KeyDefinition, KeyDefinitionOptions } from "../../../platform/state"; // eslint-disable-next-line -- `StateDefinition` used as an argument import { StateDefinition } from "../../../platform/state/state-definition"; +import { ClassifiedFormat } from "./classified-format"; import { SecretClassifier } from "./secret-classifier"; /** Encryption and storage settings for data stored by a `SecretState`. @@ -18,6 +19,20 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec readonly reconstruct: ([inners, ids]: (readonly [Id, any])[]) => Outer, ) {} + /** Converts the secret key to the `KeyDefinition` used for secret storage. */ + toEncryptedStateKey() { + const secretKey = new KeyDefinition<ClassifiedFormat<Id, Disclosed>[]>( + this.stateDefinition, + this.key, + { + cleanupDelayMs: this.options.cleanupDelayMs, + deserializer: (jsonValue) => jsonValue as ClassifiedFormat<Id, Disclosed>[], + }, + ); + + return secretKey; + } + /** * Define a secret state for a single value * @param stateDefinition The domain of the secret's durable state. diff --git a/libs/common/src/tools/generator/state/secret-state.spec.ts b/libs/common/src/tools/generator/state/secret-state.spec.ts index 364116fed3b..1f5e14dde93 100644 --- a/libs/common/src/tools/generator/state/secret-state.spec.ts +++ b/libs/common/src/tools/generator/state/secret-state.spec.ts @@ -36,26 +36,26 @@ const FOOBAR_RECORD = SecretKeyDefinition.record(GENERATOR_DISK, "fooBar", class const SomeUser = "some user" as UserId; -function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor<FooBar> { +function mockEncryptor<T>(fooBar: T[] = []): UserEncryptor { // stores "encrypted values" so that they can be "decrypted" later // while allowing the operations to be interleaved. const encrypted = new Map<string, Jsonify<FooBar>>( - fooBar.map((fb) => [toKey(fb).encryptedString, toValue(fb)] as const), + fooBar.map((fb) => [toKey(fb as any).encryptedString, toValue(fb)] as const), ); - const result = mock<UserEncryptor<FooBar>>({ - encrypt(value: FooBar, user: UserId) { - const encString = toKey(value); + const result = mock<UserEncryptor>({ + encrypt<T>(value: Jsonify<T>, user: UserId) { + const encString = toKey(value as any); encrypted.set(encString.encryptedString, toValue(value)); return Promise.resolve(encString); }, decrypt(secret: EncString, userId: UserId) { - const decString = encrypted.get(toValue(secret.encryptedString)); - return Promise.resolve(decString); + const decValue = encrypted.get(secret.encryptedString); + return Promise.resolve(decValue as any); }, }); - function toKey(value: FooBar) { + function toKey(value: Jsonify<T>) { // `stringify` is only relevant for its uniqueness as a key // to `encrypted`. return makeEncString(JSON.stringify(value)); @@ -68,7 +68,7 @@ function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor<FooBar> { // typescript pops a false positive about missing `encrypt` and `decrypt` // functions, so assert the type manually. - return result as unknown as UserEncryptor<FooBar>; + return result as unknown as UserEncryptor; } async function fakeStateProvider() { @@ -77,7 +77,7 @@ async function fakeStateProvider() { return stateProvider; } -describe("UserEncryptor", () => { +describe("SecretState", () => { describe("from", () => { it("returns a state store", async () => { const provider = await fakeStateProvider(); diff --git a/libs/common/src/tools/generator/state/secret-state.ts b/libs/common/src/tools/generator/state/secret-state.ts index a879b9f7889..dc4ee119a60 100644 --- a/libs/common/src/tools/generator/state/secret-state.ts +++ b/libs/common/src/tools/generator/state/secret-state.ts @@ -1,11 +1,7 @@ -import { Observable, concatMap, of, zip, map } from "rxjs"; -import { Jsonify } from "type-fest"; +import { Observable, map, concatMap, share, ReplaySubject, timer } from "rxjs"; import { EncString } from "../../../platform/models/domain/enc-string"; import { - DeriveDefinition, - DerivedState, - KeyDefinition, SingleUserState, StateProvider, StateUpdateOptions, @@ -13,28 +9,11 @@ import { } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { ClassifiedFormat } from "./classified-format"; import { SecretKeyDefinition } from "./secret-key-definition"; import { UserEncryptor } from "./user-encryptor.abstraction"; -/** Describes the structure of data stored by the SecretState's - * encrypted state. Notably, this interface ensures that `Disclosed` - * round trips through JSON serialization. It also preserves the - * Id. - * @remarks Tuple representation chosen because it matches - * `Object.entries` format. - */ -type ClassifiedFormat<Id, Disclosed> = { - /** Identifies records. `null` when storing a `value` */ - readonly id: Id | null; - /** Serialized {@link EncString} of the secret state's - * secret-level classified data. - */ - readonly secret: string; - /** serialized representation of the secret state's - * disclosed-level classified data. - */ - readonly disclosed: Jsonify<Disclosed>; -}; +const ONE_MINUTE = 1000 * 60; /** Stores account-specific secrets protected by a UserKeyEncryptor. * @@ -51,17 +30,34 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret> // wiring the derived and secret states together. private constructor( private readonly key: SecretKeyDefinition<Outer, Id, Plaintext, Disclosed, Secret>, - private readonly encryptor: UserEncryptor<Secret>, - private readonly encrypted: SingleUserState<ClassifiedFormat<Id, Disclosed>[]>, - private readonly plaintext: DerivedState<Outer>, + private readonly encryptor: UserEncryptor, + userId: UserId, + provider: StateProvider, ) { - this.state$ = plaintext.state$; - this.combinedState$ = plaintext.state$.pipe(map((state) => [this.encrypted.userId, state])); + // construct the backing store + this.encryptedState = provider.getUser(userId, key.toEncryptedStateKey()); + + // cache plaintext + this.combinedState$ = this.encryptedState.combinedState$.pipe( + concatMap( + async ([userId, state]) => [userId, await this.declassifyAll(state)] as [UserId, Outer], + ), + share({ + connector: () => { + return new ReplaySubject<[UserId, Outer]>(1); + }, + resetOnRefCountZero: () => timer(key.options.cleanupDelayMs ?? ONE_MINUTE), + }), + ); + + this.state$ = this.combinedState$.pipe(map(([, state]) => state)); } + private readonly encryptedState: SingleUserState<ClassifiedFormat<Id, Disclosed>[]>; + /** {@link SingleUserState.userId} */ get userId() { - return this.encrypted.userId; + return this.encryptedState.userId; } /** Observes changes to the decrypted secret state. The observer @@ -89,67 +85,71 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret> userId: UserId, key: SecretKeyDefinition<Outer, Id, TFrom, Disclosed, Secret>, provider: StateProvider, - encryptor: UserEncryptor<Secret>, + encryptor: UserEncryptor, ) { - // construct encrypted backing store while avoiding collisions between the derived key and the - // backing storage key. - const secretKey = new KeyDefinition<ClassifiedFormat<Id, Disclosed>[]>( - key.stateDefinition, - key.key, - { - cleanupDelayMs: key.options.cleanupDelayMs, - // FIXME: When the fakes run deserializers and serialization can be guaranteed through - // state providers, decode `jsonValue.secret` instead of it running in `derive`. - deserializer: (jsonValue) => jsonValue as ClassifiedFormat<Id, Disclosed>[], - }, - ); - const encryptedState = provider.getUser(userId, secretKey); - - // construct plaintext store - const plaintextDefinition = DeriveDefinition.from<ClassifiedFormat<Id, Disclosed>[], Outer>( - secretKey, - { - derive: async (from) => { - // fail fast if there's no value - if (from === null || from === undefined) { - return null; - } - - // decrypt each item - const decryptTasks = from.map(async ({ id, secret, disclosed }) => { - const encrypted = EncString.fromJSON(secret); - const decrypted = await encryptor.decrypt(encrypted, encryptedState.userId); - - const declassified = key.classifier.declassify(disclosed, decrypted); - const result = key.options.deserializer(declassified); - - return [id, result] as const; - }); - - // reconstruct expected type - const results = await Promise.all(decryptTasks); - const result = key.reconstruct(results); - - return result; - }, - // wire in the caller's deserializer for memory serialization - deserializer: (d) => { - const items = key.deconstruct(d); - const results = items.map(([k, v]) => [k, key.options.deserializer(v)] as const); - const result = key.reconstruct(results); - return result; - }, - // cache the decrypted data in memory - cleanupDelayMs: key.options.cleanupDelayMs, - }, - ); - const plaintextState = provider.getDerived(encryptedState.state$, plaintextDefinition, null); - - // wrap the encrypted and plaintext states in a `SecretState` facade - const secretState = new SecretState(key, encryptor, encryptedState, plaintextState); + const secretState = new SecretState(key, encryptor, userId, provider); return secretState; } + private async declassifyItem({ id, secret, disclosed }: ClassifiedFormat<Id, Disclosed>) { + const encrypted = EncString.fromJSON(secret); + const decrypted = await this.encryptor.decrypt(encrypted, this.encryptedState.userId); + + const declassified = this.key.classifier.declassify(disclosed, decrypted); + const result = [id, this.key.options.deserializer(declassified)] as const; + + return result; + } + + private async declassifyAll(data: ClassifiedFormat<Id, Disclosed>[]) { + // fail fast if there's no value + if (data === null || data === undefined) { + return null; + } + + // decrypt each item + const decryptTasks = data.map(async (item) => this.declassifyItem(item)); + + // reconstruct expected type + const results = await Promise.all(decryptTasks); + const result = this.key.reconstruct(results); + + return result; + } + + private async classifyItem([id, item]: [Id, Plaintext]) { + const classified = this.key.classifier.classify(item); + const encrypted = await this.encryptor.encrypt(classified.secret, this.encryptedState.userId); + + // the deserializer in the plaintextState's `derive` configuration always runs, but + // `encryptedState` is not guaranteed to serialize the data, so it's necessary to + // round-trip `encrypted` proactively. + const serialized = { + id, + secret: JSON.parse(JSON.stringify(encrypted)), + disclosed: classified.disclosed, + } as ClassifiedFormat<Id, Disclosed>; + + return serialized; + } + + private async classifyAll(data: Outer) { + // fail fast if there's no value + if (data === null || data === undefined) { + return null; + } + + // convert the object to a list format so that all encrypt and decrypt + // operations are self-similar + const desconstructed = this.key.deconstruct(data); + + // encrypt each value individually + const classifyTasks = desconstructed.map(async (item) => this.classifyItem(item)); + const classified = await Promise.all(classifyTasks); + + return classified; + } + /** Updates the secret stored by this state. * @param configureState a callback that returns an updated decrypted * secret state. The callback receives the state's present value as its @@ -167,71 +167,30 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret> configureState: (state: Outer, dependencies: TCombine) => Outer, options: StateUpdateOptions<Outer, TCombine> = null, ): Promise<Outer> { - // reactively grab the latest state from the caller. `zip` requires each - // observable has a value, so `combined$` provides a default if necessary. - const combined$ = options?.combineLatestWith ?? of(undefined); - const newState$ = zip(this.plaintext.state$, combined$).pipe( - concatMap(([currentState, combined]) => - this.prepareCryptoState( - currentState, - () => options?.shouldUpdate?.(currentState, combined) ?? true, - () => configureState(currentState, combined), - ), - ), - ); - - // update the backing store - let latestValue: Outer = null; - await this.encrypted.update((_, [, newStoredState]) => newStoredState, { - combineLatestWith: newState$, - shouldUpdate: (_, [shouldUpdate, , newState]) => { - // need to grab the latest value from the closure since the derived state - // could return its cached value, and this must be done in `shouldUpdate` - // because `configureState` may not run. - latestValue = newState; - return shouldUpdate; + // read the backing store + let latestClassified: ClassifiedFormat<Id, Disclosed>[]; + let latestCombined: TCombine; + await this.encryptedState.update((c) => c, { + shouldUpdate: (latest, combined) => { + latestClassified = latest; + latestCombined = combined; + return false; }, + combineLatestWith: options?.combineLatestWith, }); - return latestValue; - } - - private async prepareCryptoState( - currentState: Outer, - shouldUpdate: () => boolean, - configureState: () => Outer, - ): Promise<[boolean, ClassifiedFormat<Id, Disclosed>[], Outer]> { - // determine whether an update is necessary - if (!shouldUpdate()) { - return [false, undefined, currentState]; + // exit early if there's no update to apply + const latestDeclassified = await this.declassifyAll(latestClassified); + const shouldUpdate = options?.shouldUpdate?.(latestDeclassified, latestCombined) ?? true; + if (!shouldUpdate) { + return latestDeclassified; } - // calculate the update - const newState = configureState(); - if (newState === null || newState === undefined) { - return [true, newState as any, newState]; - } + // apply the update + const updatedDeclassified = configureState(latestDeclassified, latestCombined); + const updatedClassified = await this.classifyAll(updatedDeclassified); + await this.encryptedState.update(() => updatedClassified); - // convert the object to a list format so that all encrypt and decrypt - // operations are self-similar - const desconstructed = this.key.deconstruct(newState); - - // encrypt each value individually - const encryptTasks = desconstructed.map(async ([id, state]) => { - const classified = this.key.classifier.classify(state); - const encrypted = await this.encryptor.encrypt(classified.secret, this.encrypted.userId); - - // the deserializer in the plaintextState's `derive` configuration always runs, but - // `encryptedState` is not guaranteed to serialize the data, so it's necessary to - // round-trip it proactively. This will cause some duplicate work in those situations - // where the backing store does deserialize the data. - const serialized = JSON.parse( - JSON.stringify({ id, secret: encrypted, disclosed: classified.disclosed }), - ); - return serialized as ClassifiedFormat<Id, Disclosed>; - }); - const serializedState = await Promise.all(encryptTasks); - - return [true, serializedState, newState]; + return updatedDeclassified; } } diff --git a/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts b/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts index 2009c6f255f..76539a0edf2 100644 --- a/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts +++ b/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts @@ -7,7 +7,7 @@ import { UserId } from "../../../types/guid"; * user-specific information. The specific kind of information is * determined by the classification strategy. */ -export abstract class UserEncryptor<Secret> { +export abstract class UserEncryptor { /** Protects secrets in `value` with a user-specific key. * @param secret the object to protect. This object is mutated during encryption. * @param userId identifies the user-specific information used to protect @@ -17,7 +17,7 @@ export abstract class UserEncryptor<Secret> { * properties. * @throws If `value` is `null` or `undefined`, the promise rejects with an error. */ - abstract encrypt(secret: Secret, userId: UserId): Promise<EncString>; + abstract encrypt<Secret>(secret: Jsonify<Secret>, userId: UserId): Promise<EncString>; /** Combines protected secrets and disclosed data into a type that can be * rehydrated into a domain object. @@ -30,5 +30,5 @@ export abstract class UserEncryptor<Secret> { * @throws If `secret` or `disclosed` is `null` or `undefined`, the promise * rejects with an error. */ - abstract decrypt(secret: EncString, userId: UserId): Promise<Jsonify<Secret>>; + abstract decrypt<Secret>(secret: EncString, userId: UserId): Promise<Jsonify<Secret>>; } diff --git a/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts b/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts index 9289086986b..072f7bd8f34 100644 --- a/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts +++ b/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts @@ -39,10 +39,10 @@ describe("UserKeyEncryptor", () => { it("should throw if value was not supplied", async () => { const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); - await expect(encryptor.encrypt(null, anyUserId)).rejects.toThrow( + await expect(encryptor.encrypt<Record<string, never>>(null, anyUserId)).rejects.toThrow( "secret cannot be null or undefined", ); - await expect(encryptor.encrypt(undefined, anyUserId)).rejects.toThrow( + await expect(encryptor.encrypt<Record<string, never>>(undefined, anyUserId)).rejects.toThrow( "secret cannot be null or undefined", ); }); @@ -50,10 +50,10 @@ describe("UserKeyEncryptor", () => { it("should throw if userId was not supplied", async () => { const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); - await expect(encryptor.encrypt({} as any, null)).rejects.toThrow( + await expect(encryptor.encrypt({}, null)).rejects.toThrow( "userId cannot be null or undefined", ); - await expect(encryptor.encrypt({} as any, undefined)).rejects.toThrow( + await expect(encryptor.encrypt({}, undefined)).rejects.toThrow( "userId cannot be null or undefined", ); }); diff --git a/libs/common/src/tools/generator/state/user-key-encryptor.ts b/libs/common/src/tools/generator/state/user-key-encryptor.ts index 22dbd41140b..27724d820d0 100644 --- a/libs/common/src/tools/generator/state/user-key-encryptor.ts +++ b/libs/common/src/tools/generator/state/user-key-encryptor.ts @@ -11,7 +11,7 @@ import { UserEncryptor } from "./user-encryptor.abstraction"; /** A classification strategy that protects a type's secrets by encrypting them * with a `UserKey` */ -export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> { +export class UserKeyEncryptor extends UserEncryptor { /** Instantiates the encryptor * @param encryptService protects properties of `Secret`. * @param keyService looks up the user key when protecting data. @@ -26,7 +26,7 @@ export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> { } /** {@link UserEncryptor.encrypt} */ - async encrypt(secret: Secret, userId: UserId): Promise<EncString> { + async encrypt<Secret>(secret: Jsonify<Secret>, userId: UserId): Promise<EncString> { this.assertHasValue("secret", secret); this.assertHasValue("userId", userId); @@ -42,7 +42,7 @@ export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> { } /** {@link UserEncryptor.decrypt} */ - async decrypt(secret: EncString, userId: UserId): Promise<Jsonify<Secret>> { + async decrypt<Secret>(secret: EncString, userId: UserId): Promise<Jsonify<Secret>> { this.assertHasValue("secret", secret); this.assertHasValue("userId", userId); diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts index dafb55febab..339e4b27203 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts @@ -1,4 +1,5 @@ import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models @@ -12,39 +13,26 @@ import { CATCHALL_SETTINGS } from "../key-definitions"; import { CatchallGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; const SomeUser = "some user" as UserId; +const SomePolicy = mock<Policy>({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + }, +}); describe("Email subaddress list generation strategy", () => { - describe("evaluator()", () => { - it("should throw if the policy type is incorrect", () => { - const strategy = new CatchallGeneratorStrategy(null, null); - const policy = mock<Policy>({ - type: PolicyType.DisableSend, - }); + describe("toEvaluator()", () => { + it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( + "should map any input (= %p) to the default policy evaluator", + async (policies) => { + const strategy = new CatchallGeneratorStrategy(null, null); - expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+")); - }); + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); - it("should map to the policy evaluator", () => { - const strategy = new CatchallGeneratorStrategy(null, null); - const policy = mock<Policy>({ - type: PolicyType.PasswordGenerator, - data: { - minLength: 10, - }, - }); - - const evaluator = strategy.evaluator(policy); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - expect(evaluator.policy).toMatchObject({}); - }); - - it("should map `null` to a default policy evaluator", () => { - const strategy = new CatchallGeneratorStrategy(null, null); - const evaluator = strategy.evaluator(null); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - }); + expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); + }, + ); }); describe("durableState", () => { diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts index aadca78b3b4..6b36ebd50b5 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts @@ -1,5 +1,6 @@ +import { map, pipe } from "rxjs"; + import { PolicyType } from "../../../admin-console/enums"; -import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; @@ -41,18 +42,9 @@ export class CatchallGeneratorStrategy return ONE_MINUTE; } - /** {@link GeneratorStrategy.evaluator} */ - evaluator(policy: Policy) { - if (!policy) { - return new DefaultPolicyEvaluator<CatchallGenerationOptions>(); - } - - if (policy.type !== this.policy) { - const details = `Expected: ${this.policy}. Received: ${policy.type}`; - throw Error("Mismatched policy type. " + details); - } - - return new DefaultPolicyEvaluator<CatchallGenerationOptions>(); + /** {@link GeneratorStrategy.toEvaluator} */ + toEvaluator() { + return pipe(map((_) => new DefaultPolicyEvaluator<CatchallGenerationOptions>())); } /** {@link GeneratorStrategy.generate} */ diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts index 0fb5bf573c0..821b4bb7dc8 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts @@ -1,4 +1,5 @@ import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models @@ -12,39 +13,26 @@ import { EFF_USERNAME_SETTINGS } from "../key-definitions"; import { EffUsernameGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; const SomeUser = "some user" as UserId; +const SomePolicy = mock<Policy>({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + }, +}); describe("EFF long word list generation strategy", () => { - describe("evaluator()", () => { - it("should throw if the policy type is incorrect", () => { - const strategy = new EffUsernameGeneratorStrategy(null, null); - const policy = mock<Policy>({ - type: PolicyType.DisableSend, - }); + describe("toEvaluator()", () => { + it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( + "should map any input (= %p) to the default policy evaluator", + async (policies) => { + const strategy = new EffUsernameGeneratorStrategy(null, null); - expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+")); - }); + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); - it("should map to the policy evaluator", () => { - const strategy = new EffUsernameGeneratorStrategy(null, null); - const policy = mock<Policy>({ - type: PolicyType.PasswordGenerator, - data: { - minLength: 10, - }, - }); - - const evaluator = strategy.evaluator(policy); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - expect(evaluator.policy).toMatchObject({}); - }); - - it("should map `null` to a default policy evaluator", () => { - const strategy = new EffUsernameGeneratorStrategy(null, null); - const evaluator = strategy.evaluator(null); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - }); + expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); + }, + ); }); describe("durableState", () => { diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts index e0179895ae3..133b4e77776 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts @@ -1,5 +1,6 @@ +import { map, pipe } from "rxjs"; + import { PolicyType } from "../../../admin-console/enums"; -import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; @@ -41,18 +42,9 @@ export class EffUsernameGeneratorStrategy return ONE_MINUTE; } - /** {@link GeneratorStrategy.evaluator} */ - evaluator(policy: Policy) { - if (!policy) { - return new DefaultPolicyEvaluator<EffUsernameGenerationOptions>(); - } - - if (policy.type !== this.policy) { - const details = `Expected: ${this.policy}. Received: ${policy.type}`; - throw Error("Mismatched policy type. " + details); - } - - return new DefaultPolicyEvaluator<EffUsernameGenerationOptions>(); + /** {@link GeneratorStrategy.toEvaluator} */ + toEvaluator() { + return pipe(map((_) => new DefaultPolicyEvaluator<EffUsernameGenerationOptions>())); } /** {@link GeneratorStrategy.generate} */ diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts index 96a7bca2b1d..30dd6204843 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts @@ -1,6 +1,11 @@ import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; +import { PolicyType } from "../../../admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "../../../admin-console/models/domain/policy"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { StateProvider } from "../../../platform/state"; @@ -29,6 +34,12 @@ class TestForwarder extends ForwarderGeneratorStrategy<ApiOptions> { const SomeUser = "some user" as UserId; const AnotherUser = "another user" as UserId; +const SomePolicy = mock<Policy>({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + }, +}); describe("ForwarderGeneratorStrategy", () => { const encryptService = mock<EncryptService>(); @@ -63,11 +74,17 @@ describe("ForwarderGeneratorStrategy", () => { }); }); - it("evaluator returns the default policy evaluator", () => { - const strategy = new TestForwarder(null, null, null); + describe("toEvaluator()", () => { + it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( + "should map any input (= %p) to the default policy evaluator", + async (policies) => { + const strategy = new TestForwarder(encryptService, keyService, stateProvider); - const result = strategy.evaluator(null); + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); - expect(result).toBeInstanceOf(DefaultPolicyEvaluator); + expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); + }, + ); }); }); diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts index b0717695e05..8b78f22634e 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts @@ -1,5 +1,6 @@ +import { map, pipe } from "rxjs"; + import { PolicyType } from "../../../admin-console/enums"; -import { Policy } from "../../../admin-console/models/domain/policy"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { KeyDefinition, SingleUserState, StateProvider } from "../../../platform/state"; @@ -81,8 +82,8 @@ export abstract class ForwarderGeneratorStrategy< /** Determine where forwarder configuration is stored */ protected abstract readonly key: KeyDefinition<Options>; - /** {@link GeneratorStrategy.evaluator} */ - evaluator = (_policy: Policy) => { - return new DefaultPolicyEvaluator<Options>(); + /** {@link GeneratorStrategy.toEvaluator} */ + toEvaluator = () => { + return pipe(map((_) => new DefaultPolicyEvaluator<Options>())); }; } diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts index 105edd6b4df..59a2b56172a 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts @@ -1,4 +1,5 @@ import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; import { PolicyType } from "../../../admin-console/enums"; // FIXME: use index.ts imports once policy abstractions and models @@ -12,39 +13,26 @@ import { SUBADDRESS_SETTINGS } from "../key-definitions"; import { SubaddressGeneratorStrategy, UsernameGenerationServiceAbstraction } from "."; const SomeUser = "some user" as UserId; +const SomePolicy = mock<Policy>({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + }, +}); describe("Email subaddress list generation strategy", () => { - describe("evaluator()", () => { - it("should throw if the policy type is incorrect", () => { - const strategy = new SubaddressGeneratorStrategy(null, null); - const policy = mock<Policy>({ - type: PolicyType.DisableSend, - }); + describe("toEvaluator()", () => { + it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( + "should map any input (= %p) to the default policy evaluator", + async (policies) => { + const strategy = new SubaddressGeneratorStrategy(null, null); - expect(() => strategy.evaluator(policy)).toThrow(new RegExp("Mismatched policy type\\. .+")); - }); + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); - it("should map to the policy evaluator", () => { - const strategy = new SubaddressGeneratorStrategy(null, null); - const policy = mock<Policy>({ - type: PolicyType.PasswordGenerator, - data: { - minLength: 10, - }, - }); - - const evaluator = strategy.evaluator(policy); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - expect(evaluator.policy).toMatchObject({}); - }); - - it("should map `null` to a default policy evaluator", () => { - const strategy = new SubaddressGeneratorStrategy(null, null); - const evaluator = strategy.evaluator(null); - - expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); - }); + expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); + }, + ); }); describe("durableState", () => { diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts index 1aba473476d..1ae0cb91427 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts @@ -1,5 +1,6 @@ +import { map, pipe } from "rxjs"; + import { PolicyType } from "../../../admin-console/enums"; -import { Policy } from "../../../admin-console/models/domain/policy"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; @@ -41,18 +42,9 @@ export class SubaddressGeneratorStrategy return ONE_MINUTE; } - /** {@link GeneratorStrategy.evaluator} */ - evaluator(policy: Policy) { - if (!policy) { - return new DefaultPolicyEvaluator<SubaddressGenerationOptions>(); - } - - if (policy.type !== this.policy) { - const details = `Expected: ${this.policy}. Received: ${policy.type}`; - throw Error("Mismatched policy type. " + details); - } - - return new DefaultPolicyEvaluator<SubaddressGenerationOptions>(); + /** {@link GeneratorStrategy.toEvaluator} */ + toEvaluator() { + return pipe(map((_) => new DefaultPolicyEvaluator<SubaddressGenerationOptions>())); } /** {@link GeneratorStrategy.generate} */ diff --git a/libs/common/src/tools/send/services/key-definitions.spec.ts b/libs/common/src/tools/send/services/key-definitions.spec.ts new file mode 100644 index 00000000000..9916237349b --- /dev/null +++ b/libs/common/src/tools/send/services/key-definitions.spec.ts @@ -0,0 +1,21 @@ +import { SEND_USER_ENCRYPTED, SEND_USER_DECRYPTED } from "./key-definitions"; +import { testSendData, testSendViewData } from "./test-data/send-tests.data"; + +describe("Key definitions", () => { + describe("SEND_USER_ENCRYPTED", () => { + it("should pass through deserialization", () => { + const result = SEND_USER_ENCRYPTED.deserializer( + JSON.parse(JSON.stringify(testSendData("1", "Test Send Data"))), + ); + expect(result).toEqual(testSendData("1", "Test Send Data")); + }); + }); + + describe("SEND_USER_DECRYPTED", () => { + it("should pass through deserialization", () => { + const sendViews = [testSendViewData("1", "Test Send View")]; + const result = SEND_USER_DECRYPTED.deserializer(JSON.parse(JSON.stringify(sendViews))); + expect(result).toEqual(sendViews); + }); + }); +}); diff --git a/libs/common/src/tools/send/services/key-definitions.ts b/libs/common/src/tools/send/services/key-definitions.ts new file mode 100644 index 00000000000..b117c522686 --- /dev/null +++ b/libs/common/src/tools/send/services/key-definitions.ts @@ -0,0 +1,13 @@ +import { KeyDefinition, SEND_DISK, SEND_MEMORY } from "../../../platform/state"; +import { SendData } from "../models/data/send.data"; +import { SendView } from "../models/view/send.view"; + +/** Encrypted send state stored on disk */ +export const SEND_USER_ENCRYPTED = KeyDefinition.record<SendData>(SEND_DISK, "sendUserEncrypted", { + deserializer: (obj: SendData) => obj, +}); + +/** Decrypted send state stored in memory */ +export const SEND_USER_DECRYPTED = new KeyDefinition<SendView[]>(SEND_MEMORY, "sendUserDecrypted", { + deserializer: (obj) => obj, +}); diff --git a/libs/common/src/tools/send/services/send-state.provider.abstraction.ts b/libs/common/src/tools/send/services/send-state.provider.abstraction.ts new file mode 100644 index 00000000000..7a35506b56c --- /dev/null +++ b/libs/common/src/tools/send/services/send-state.provider.abstraction.ts @@ -0,0 +1,17 @@ +import { Observable } from "rxjs"; + +import { SendData } from "../models/data/send.data"; +import { SendView } from "../models/view/send.view"; + +export abstract class SendStateProvider { + encryptedState$: Observable<Record<string, SendData>>; + decryptedState$: Observable<SendView[]>; + + getEncryptedSends: () => Promise<{ [id: string]: SendData }>; + + setEncryptedSends: (value: { [id: string]: SendData }) => Promise<void>; + + getDecryptedSends: () => Promise<SendView[]>; + + setDecryptedSends: (value: SendView[]) => Promise<void>; +} diff --git a/libs/common/src/tools/send/services/send-state.provider.spec.ts b/libs/common/src/tools/send/services/send-state.provider.spec.ts new file mode 100644 index 00000000000..069e0d80697 --- /dev/null +++ b/libs/common/src/tools/send/services/send-state.provider.spec.ts @@ -0,0 +1,48 @@ +import { + FakeAccountService, + FakeStateProvider, + awaitAsync, + mockAccountServiceWith, +} from "../../../../spec"; +import { Utils } from "../../../platform/misc/utils"; +import { UserId } from "../../../types/guid"; + +import { SendStateProvider } from "./send-state.provider"; +import { testSendData, testSendViewData } from "./test-data/send-tests.data"; + +describe("Send State Provider", () => { + let stateProvider: FakeStateProvider; + let accountService: FakeAccountService; + let sendStateProvider: SendStateProvider; + + const mockUserId = Utils.newGuid() as UserId; + + beforeEach(() => { + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + + sendStateProvider = new SendStateProvider(stateProvider); + }); + + describe("Encrypted Sends", () => { + it("should return SendData", async () => { + const sendData = { "1": testSendData("1", "Test Send Data") }; + await sendStateProvider.setEncryptedSends(sendData); + await awaitAsync(); + + const actual = await sendStateProvider.getEncryptedSends(); + expect(actual).toStrictEqual(sendData); + }); + }); + + describe("Decrypted Sends", () => { + it("should return SendView", async () => { + const state = [testSendViewData("1", "Test")]; + await sendStateProvider.setDecryptedSends(state); + await awaitAsync(); + + const actual = await sendStateProvider.getDecryptedSends(); + expect(actual).toStrictEqual(state); + }); + }); +}); diff --git a/libs/common/src/tools/send/services/send-state.provider.ts b/libs/common/src/tools/send/services/send-state.provider.ts new file mode 100644 index 00000000000..1e9397b7a9d --- /dev/null +++ b/libs/common/src/tools/send/services/send-state.provider.ts @@ -0,0 +1,47 @@ +import { Observable, firstValueFrom } from "rxjs"; + +import { ActiveUserState, StateProvider } from "../../../platform/state"; +import { SendData } from "../models/data/send.data"; +import { SendView } from "../models/view/send.view"; + +import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions"; +import { SendStateProvider as SendStateProviderAbstraction } from "./send-state.provider.abstraction"; + +/** State provider for sends */ +export class SendStateProvider implements SendStateProviderAbstraction { + /** Observable for the encrypted sends for an active user */ + encryptedState$: Observable<Record<string, SendData>>; + /** Observable with the decrypted sends for an active user */ + decryptedState$: Observable<SendView[]>; + + private activeUserEncryptedState: ActiveUserState<Record<string, SendData>>; + private activeUserDecryptedState: ActiveUserState<SendView[]>; + + constructor(protected stateProvider: StateProvider) { + this.activeUserEncryptedState = this.stateProvider.getActive(SEND_USER_ENCRYPTED); + this.encryptedState$ = this.activeUserEncryptedState.state$; + + this.activeUserDecryptedState = this.stateProvider.getActive(SEND_USER_DECRYPTED); + this.decryptedState$ = this.activeUserDecryptedState.state$; + } + + /** Gets the encrypted sends from state for an active user */ + async getEncryptedSends(): Promise<{ [id: string]: SendData }> { + return await firstValueFrom(this.encryptedState$); + } + + /** Sets the encrypted send state for an active user */ + async setEncryptedSends(value: { [id: string]: SendData }): Promise<void> { + await this.activeUserEncryptedState.update(() => value); + } + + /** Gets the decrypted sends from state for the active user */ + async getDecryptedSends(): Promise<SendView[]> { + return await firstValueFrom(this.decryptedState$); + } + + /** Sets the decrypted send state for an active user */ + async setDecryptedSends(value: SendView[]): Promise<void> { + await this.activeUserDecryptedState.update(() => value); + } +} diff --git a/libs/common/src/tools/send/services/send.service.abstraction.ts b/libs/common/src/tools/send/services/send.service.abstraction.ts index 45f623537db..e9f93871699 100644 --- a/libs/common/src/tools/send/services/send.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send.service.abstraction.ts @@ -18,10 +18,6 @@ export abstract class SendService { password: string, key?: SymmetricCryptoKey, ) => Promise<[Send, EncArrayBuffer]>; - /** - * @deprecated Do not call this, use the get$ method - */ - get: (id: string) => Send; /** * Provides a send for a determined id * updates after a change occurs to the send that matches the id @@ -53,6 +49,5 @@ export abstract class SendService { export abstract class InternalSendService extends SendService { upsert: (send: SendData | SendData[]) => Promise<any>; replace: (sends: { [id: string]: SendData }) => Promise<void>; - clear: (userId: string) => Promise<any>; delete: (id: string | string[]) => Promise<any>; } diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 568bd70d525..fc793dba67a 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -1,14 +1,23 @@ -import { any, mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; +import { mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; +import { + FakeAccountService, + FakeActiveUserState, + FakeStateProvider, + awaitAsync, + mockAccountServiceWith, +} from "../../../../spec"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; -import { StateService } from "../../../platform/abstractions/state.service"; +import { Utils } from "../../../platform/misc/utils"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../../platform/services/container.service"; +import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; import { SendType } from "../enums/send-type"; import { SendFileApi } from "../models/api/send-file.api"; @@ -16,10 +25,17 @@ import { SendTextApi } from "../models/api/send-text.api"; import { SendFileData } from "../models/data/send-file.data"; import { SendTextData } from "../models/data/send-text.data"; import { SendData } from "../models/data/send.data"; -import { Send } from "../models/domain/send"; import { SendView } from "../models/view/send.view"; +import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions"; +import { SendStateProvider } from "./send-state.provider"; import { SendService } from "./send.service"; +import { + createSendData, + testSend, + testSendData, + testSendViewData, +} from "./test-data/send-tests.data"; describe("SendService", () => { const cryptoService = mock<CryptoService>(); @@ -27,56 +43,53 @@ describe("SendService", () => { const keyGenerationService = mock<KeyGenerationService>(); const encryptService = mock<EncryptService>(); + let sendStateProvider: SendStateProvider; let sendService: SendService; - let stateService: MockProxy<StateService>; - let activeAccount: BehaviorSubject<string>; - let activeAccountUnlocked: BehaviorSubject<boolean>; + let stateProvider: FakeStateProvider; + let encryptedState: FakeActiveUserState<Record<string, SendData>>; + let decryptedState: FakeActiveUserState<SendView[]>; + + const mockUserId = Utils.newGuid() as UserId; + let accountService: FakeAccountService; beforeEach(() => { - activeAccount = new BehaviorSubject("123"); - activeAccountUnlocked = new BehaviorSubject(true); + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + sendStateProvider = new SendStateProvider(stateProvider); - stateService = mock<StateService>(); - stateService.activeAccount$ = activeAccount; - stateService.activeAccountUnlocked$ = activeAccountUnlocked; (window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService); - stateService.getEncryptedSends.calledWith(any()).mockResolvedValue({ - "1": sendData("1", "Test Send"), + accountService.activeAccountSubject.next({ + id: mockUserId, + email: "email", + name: "name", + status: AuthenticationStatus.Unlocked, }); - stateService.getDecryptedSends - .calledWith(any()) - .mockResolvedValue([sendView("1", "Test Send")]); - - sendService = new SendService(cryptoService, i18nService, keyGenerationService, stateService); - }); - - afterEach(() => { - activeAccount.complete(); - activeAccountUnlocked.complete(); - }); - - describe("get", () => { - it("exists", async () => { - const result = sendService.get("1"); - - expect(result).toEqual(send("1", "Test Send")); + // Initial encrypted state + encryptedState = stateProvider.activeUser.getFake(SEND_USER_ENCRYPTED); + encryptedState.nextState({ + "1": testSendData("1", "Test Send"), }); + // Initial decrypted state + decryptedState = stateProvider.activeUser.getFake(SEND_USER_DECRYPTED); + decryptedState.nextState([testSendViewData("1", "Test Send")]); - it("does not exist", async () => { - const result = sendService.get("2"); - - expect(result).toBe(undefined); - }); + sendService = new SendService( + cryptoService, + i18nService, + keyGenerationService, + sendStateProvider, + encryptService, + ); }); describe("get$", () => { it("exists", async () => { const result = await firstValueFrom(sendService.get$("1")); - expect(result).toEqual(send("1", "Test Send")); + expect(result).toEqual(testSend("1", "Test Send")); }); it("does not exist", async () => { @@ -88,14 +101,14 @@ describe("SendService", () => { it("updated observable", async () => { const singleSendObservable = sendService.get$("1"); const result = await firstValueFrom(singleSendObservable); - expect(result).toEqual(send("1", "Test Send")); + expect(result).toEqual(testSend("1", "Test Send")); await sendService.replace({ - "1": sendData("1", "Test Send Updated"), + "1": testSendData("1", "Test Send Updated"), }); const result2 = await firstValueFrom(singleSendObservable); - expect(result2).toEqual(send("1", "Test Send Updated")); + expect(result2).toEqual(testSend("1", "Test Send Updated")); }); it("reports a change when name changes on a new send", async () => { @@ -103,13 +116,13 @@ describe("SendService", () => { sendService.get$("1").subscribe(() => { changed = true; }); - const sendDataObject = sendData("1", "Test Send 2"); + const sendDataObject = testSendData("1", "Test Send 2"); //it is immediately called when subscribed, we need to reset the value changed = false; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -120,7 +133,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -134,7 +147,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -145,7 +158,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -159,7 +172,7 @@ describe("SendService", () => { sendDataObject.text.text = "new text"; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -170,7 +183,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -184,7 +197,7 @@ describe("SendService", () => { sendDataObject.text = null; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -197,7 +210,7 @@ describe("SendService", () => { }) as SendData; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); sendDataObject.file = new SendFileData(new SendFileApi({ FileName: "updated name of file" })); @@ -211,7 +224,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(false); @@ -222,7 +235,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -236,7 +249,7 @@ describe("SendService", () => { sendDataObject.key = "newKey"; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -247,7 +260,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -261,7 +274,7 @@ describe("SendService", () => { sendDataObject.revisionDate = "2025-04-05"; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -272,7 +285,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -286,7 +299,7 @@ describe("SendService", () => { sendDataObject.name = null; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -299,7 +312,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); let changed = false; @@ -312,7 +325,7 @@ describe("SendService", () => { await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(false); @@ -320,7 +333,7 @@ describe("SendService", () => { sendDataObject.text.text = "Asdf"; await sendService.replace({ "1": sendDataObject, - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -332,14 +345,14 @@ describe("SendService", () => { changed = true; }); - const sendDataObject = sendData("1", "Test Send"); + const sendDataObject = testSendData("1", "Test Send"); //it is immediately called when subscribed, we need to reset the value changed = false; await sendService.replace({ "1": sendDataObject, - "2": sendData("3", "Test Send 3"), + "2": testSendData("3", "Test Send 3"), }); expect(changed).toEqual(false); @@ -354,7 +367,7 @@ describe("SendService", () => { changed = false; await sendService.replace({ - "2": sendData("2", "Test Send 2"), + "2": testSendData("2", "Test Send 2"), }); expect(changed).toEqual(true); @@ -366,14 +379,14 @@ describe("SendService", () => { const send1 = sends[0]; expect(sends).toHaveLength(1); - expect(send1).toEqual(send("1", "Test Send")); + expect(send1).toEqual(testSend("1", "Test Send")); }); describe("getFromState", () => { it("exists", async () => { const result = await sendService.getFromState("1"); - expect(result).toEqual(send("1", "Test Send")); + expect(result).toEqual(testSend("1", "Test Send")); }); it("does not exist", async () => { const result = await sendService.getFromState("2"); @@ -383,17 +396,17 @@ describe("SendService", () => { }); it("getAllDecryptedFromState", async () => { - await sendService.getAllDecryptedFromState(); + const sends = await sendService.getAllDecryptedFromState(); - expect(stateService.getDecryptedSends).toHaveBeenCalledTimes(1); + expect(sends[0]).toMatchObject(testSendViewData("1", "Test Send")); }); describe("getRotatedKeys", () => { let encryptedKey: EncString; beforeEach(() => { - cryptoService.decryptToBytes.mockResolvedValue(new Uint8Array(32)); + encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(32)); encryptedKey = new EncString("Re-encrypted Send Key"); - cryptoService.encrypt.mockResolvedValue(encryptedKey); + encryptService.encrypt.mockResolvedValue(encryptedKey); }); it("returns re-encrypted user sends", async () => { @@ -408,6 +421,8 @@ describe("SendService", () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises sendService.replace(null); + await awaitAsync(); + const newUserKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; const result = await sendService.getRotatedKeys(newUserKey); @@ -424,114 +439,51 @@ describe("SendService", () => { // InternalSendService it("upsert", async () => { - await sendService.upsert(sendData("2", "Test 2")); + await sendService.upsert(testSendData("2", "Test 2")); expect(await firstValueFrom(sendService.sends$)).toEqual([ - send("1", "Test Send"), - send("2", "Test 2"), + testSend("1", "Test Send"), + testSend("2", "Test 2"), ]); }); it("replace", async () => { - await sendService.replace({ "2": sendData("2", "test 2") }); + await sendService.replace({ "2": testSendData("2", "test 2") }); - expect(await firstValueFrom(sendService.sends$)).toEqual([send("2", "test 2")]); + expect(await firstValueFrom(sendService.sends$)).toEqual([testSend("2", "test 2")]); }); it("clear", async () => { await sendService.clear(); - + await awaitAsync(); expect(await firstValueFrom(sendService.sends$)).toEqual([]); }); + describe("Delete", () => { + it("Sends count should decrease after delete", async () => { + const sendsBeforeDelete = await firstValueFrom(sendService.sends$); + await sendService.delete(sendsBeforeDelete[0].id); - describe("delete", () => { - it("exists", async () => { - await sendService.delete("1"); - - expect(stateService.getEncryptedSends).toHaveBeenCalledTimes(2); - expect(stateService.setEncryptedSends).toHaveBeenCalledTimes(1); + const sendsAfterDelete = await firstValueFrom(sendService.sends$); + expect(sendsAfterDelete.length).toBeLessThan(sendsBeforeDelete.length); }); - it("does not exist", async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - sendService.delete("1"); + it("Intended send should be delete", async () => { + const sendsBeforeDelete = await firstValueFrom(sendService.sends$); + await sendService.delete(sendsBeforeDelete[0].id); + const sendsAfterDelete = await firstValueFrom(sendService.sends$); + expect(sendsAfterDelete[0]).not.toBe(sendsBeforeDelete[0]); + }); - expect(stateService.getEncryptedSends).toHaveBeenCalledTimes(2); + it("Deleting on an empty sends array should not throw", async () => { + sendStateProvider.getEncryptedSends = jest.fn().mockResolvedValue(null); + await expect(sendService.delete("2")).resolves.not.toThrow(); + }); + + it("Delete multiple sends", async () => { + await sendService.upsert(testSendData("2", "send data 2")); + await sendService.delete(["1", "2"]); + const sendsAfterDelete = await firstValueFrom(sendService.sends$); + expect(sendsAfterDelete.length).toBe(0); }); }); - - // Send object helper functions - - function sendData(id: string, name: string) { - const data = new SendData({} as any); - data.id = id; - data.name = name; - data.disabled = false; - data.accessCount = 2; - data.accessId = "1"; - data.revisionDate = null; - data.expirationDate = null; - data.deletionDate = null; - data.notes = "Notes!!"; - data.key = null; - return data; - } - - const defaultSendData: Partial<SendData> = { - id: "1", - name: "Test Send", - accessId: "123", - type: SendType.Text, - notes: "notes!", - file: null, - text: new SendTextData(new SendTextApi({ Text: "send text" })), - key: "key", - maxAccessCount: 12, - accessCount: 2, - revisionDate: "2024-09-04", - expirationDate: "2024-09-04", - deletionDate: "2024-09-04", - password: "password", - disabled: false, - hideEmail: false, - }; - - function createSendData(value: Partial<SendData> = {}) { - const testSend: any = {}; - for (const prop in defaultSendData) { - testSend[prop] = value[prop as keyof SendData] ?? defaultSendData[prop as keyof SendData]; - } - return testSend; - } - - function sendView(id: string, name: string) { - const data = new SendView({} as any); - data.id = id; - data.name = name; - data.disabled = false; - data.accessCount = 2; - data.accessId = "1"; - data.revisionDate = null; - data.expirationDate = null; - data.deletionDate = null; - data.notes = "Notes!!"; - data.key = null; - return data; - } - - function send(id: string, name: string) { - const data = new Send({} as any); - data.id = id; - data.name = new EncString(name); - data.disabled = false; - data.accessCount = 2; - data.accessId = "1"; - data.revisionDate = null; - data.expirationDate = null; - data.deletionDate = null; - data.notes = new EncString("Notes!!"); - data.key = null; - return data; - } }); diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 528f90c1dc1..33b1f28be0c 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -1,9 +1,9 @@ -import { BehaviorSubject, Observable, concatMap, distinctUntilChanged, map } from "rxjs"; +import { Observable, concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service"; -import { StateService } from "../../../platform/abstractions/state.service"; import { KdfType } from "../../../platform/enums"; import { Utils } from "../../../platform/misc/utils"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; @@ -19,48 +19,29 @@ import { SendWithIdRequest } from "../models/request/send-with-id.request"; import { SendView } from "../models/view/send.view"; import { SEND_KDF_ITERATIONS } from "../send-kdf"; +import { SendStateProvider } from "./send-state.provider.abstraction"; import { InternalSendService as InternalSendServiceAbstraction } from "./send.service.abstraction"; export class SendService implements InternalSendServiceAbstraction { readonly sendKeySalt = "bitwarden-send"; readonly sendKeyPurpose = "send"; - protected _sends: BehaviorSubject<Send[]> = new BehaviorSubject([]); - protected _sendViews: BehaviorSubject<SendView[]> = new BehaviorSubject([]); - - sends$ = this._sends.asObservable(); - sendViews$ = this._sendViews.asObservable(); + sends$ = this.stateProvider.encryptedState$.pipe( + map((record) => Object.values(record || {}).map((data) => new Send(data))), + ); + sendViews$ = this.stateProvider.encryptedState$.pipe( + concatMap((record) => + this.decryptSends(Object.values(record || {}).map((data) => new Send(data))), + ), + ); constructor( private cryptoService: CryptoService, private i18nService: I18nService, private keyGenerationService: KeyGenerationService, - private stateService: StateService, - ) { - this.stateService.activeAccountUnlocked$ - .pipe( - concatMap(async (unlocked) => { - if (Utils.global.bitwardenContainerService == null) { - return; - } - - if (!unlocked) { - this._sends.next([]); - this._sendViews.next([]); - return; - } - - const data = await this.stateService.getEncryptedSends(); - - await this.updateObservables(data); - }), - ) - .subscribe(); - } - - async clearCache(): Promise<void> { - await this._sendViews.next([]); - } + private stateProvider: SendStateProvider, + private encryptService: EncryptService, + ) {} async encrypt( model: SendView, @@ -93,12 +74,15 @@ export class SendService implements InternalSendServiceAbstraction { ); send.password = passwordKey.keyB64; } - send.key = await this.cryptoService.encrypt(model.key, key); - send.name = await this.cryptoService.encrypt(model.name, model.cryptoKey); - send.notes = await this.cryptoService.encrypt(model.notes, model.cryptoKey); + if (key == null) { + key = await this.cryptoService.getUserKey(); + } + send.key = await this.encryptService.encrypt(model.key, key); + send.name = await this.encryptService.encrypt(model.name, model.cryptoKey); + send.notes = await this.encryptService.encrypt(model.notes, model.cryptoKey); if (send.type === SendType.Text) { send.text = new SendText(); - send.text.text = await this.cryptoService.encrypt(model.text.text, model.cryptoKey); + send.text.text = await this.encryptService.encrypt(model.text.text, model.cryptoKey); send.text.hidden = model.text.hidden; } else if (send.type === SendType.File) { send.file = new SendFile(); @@ -120,11 +104,6 @@ export class SendService implements InternalSendServiceAbstraction { return [send, fileData]; } - get(id: string): Send { - const sends = this._sends.getValue(); - return sends.find((send) => send.id === id); - } - get$(id: string): Observable<Send | undefined> { return this.sends$.pipe( distinctUntilChanged((oldSends, newSends) => { @@ -188,7 +167,7 @@ export class SendService implements InternalSendServiceAbstraction { } async getFromState(id: string): Promise<Send> { - const sends = await this.stateService.getEncryptedSends(); + const sends = await this.stateProvider.getEncryptedSends(); // eslint-disable-next-line if (sends == null || !sends.hasOwnProperty(id)) { return null; @@ -198,7 +177,7 @@ export class SendService implements InternalSendServiceAbstraction { } async getAll(): Promise<Send[]> { - const sends = await this.stateService.getEncryptedSends(); + const sends = await this.stateProvider.getEncryptedSends(); const response: Send[] = []; for (const id in sends) { // eslint-disable-next-line @@ -210,7 +189,7 @@ export class SendService implements InternalSendServiceAbstraction { } async getAllDecryptedFromState(): Promise<SendView[]> { - let decSends = await this.stateService.getDecryptedSends(); + let decSends = await this.stateProvider.getDecryptedSends(); if (decSends != null) { return decSends; } @@ -230,12 +209,12 @@ export class SendService implements InternalSendServiceAbstraction { await Promise.all(promises); decSends.sort(Utils.getSortFunction(this.i18nService, "name")); - await this.stateService.setDecryptedSends(decSends); + await this.stateProvider.setDecryptedSends(decSends); return decSends; } async upsert(send: SendData | SendData[]): Promise<any> { - let sends = await this.stateService.getEncryptedSends(); + let sends = await this.stateProvider.getEncryptedSends(); if (sends == null) { sends = {}; } @@ -252,16 +231,12 @@ export class SendService implements InternalSendServiceAbstraction { } async clear(userId?: string): Promise<any> { - if (userId == null || userId == (await this.stateService.getUserId())) { - this._sends.next([]); - this._sendViews.next([]); - } - await this.stateService.setDecryptedSends(null, { userId: userId }); - await this.stateService.setEncryptedSends(null, { userId: userId }); + await this.stateProvider.setDecryptedSends(null); + await this.stateProvider.setEncryptedSends(null); } async delete(id: string | string[]): Promise<any> { - const sends = await this.stateService.getEncryptedSends(); + const sends = await this.stateProvider.getEncryptedSends(); if (sends == null) { return; } @@ -281,8 +256,7 @@ export class SendService implements InternalSendServiceAbstraction { } async replace(sends: { [id: string]: SendData }): Promise<any> { - await this.updateObservables(sends); - await this.stateService.setEncryptedSends(sends); + await this.stateProvider.setEncryptedSends(sends); } async getRotatedKeys(newUserKey: UserKey): Promise<SendWithIdRequest[]> { @@ -290,14 +264,21 @@ export class SendService implements InternalSendServiceAbstraction { throw new Error("New user key is required for rotation."); } + const req = await firstValueFrom( + this.sends$.pipe(concatMap(async (sends) => this.toRotatedKeyRequestMap(sends, newUserKey))), + ); + // separate return for easier debugging + return req; + } + + private async toRotatedKeyRequestMap(sends: Send[], newUserKey: UserKey) { const requests = await Promise.all( - this._sends.value.map(async (send) => { - const sendKey = await this.cryptoService.decryptToBytes(send.key); - send.key = await this.cryptoService.encrypt(sendKey, newUserKey); + sends.map(async (send) => { + const sendKey = await this.encryptService.decryptToBytes(send.key, newUserKey); + send.key = await this.encryptService.encrypt(sendKey, newUserKey); return new SendWithIdRequest(send); }), ); - // separate return for easier debugging return requests; } @@ -329,18 +310,12 @@ export class SendService implements InternalSendServiceAbstraction { data: ArrayBuffer, key: SymmetricCryptoKey, ): Promise<[EncString, EncArrayBuffer]> { - const encFileName = await this.cryptoService.encrypt(fileName, key); - const encFileData = await this.cryptoService.encryptToBytes(new Uint8Array(data), key); - return [encFileName, encFileData]; - } - - private async updateObservables(sendsMap: { [id: string]: SendData }) { - const sends = Object.values(sendsMap || {}).map((f) => new Send(f)); - this._sends.next(sends); - - if (await this.cryptoService.hasUserKey()) { - this._sendViews.next(await this.decryptSends(sends)); + if (key == null) { + key = await this.cryptoService.getUserKey(); } + const encFileName = await this.encryptService.encrypt(fileName, key); + const encFileData = await this.encryptService.encryptToBytes(new Uint8Array(data), key); + return [encFileName, encFileData]; } private async decryptSends(sends: Send[]) { diff --git a/libs/common/src/tools/send/services/test-data/send-tests.data.ts b/libs/common/src/tools/send/services/test-data/send-tests.data.ts new file mode 100644 index 00000000000..a57a39782eb --- /dev/null +++ b/libs/common/src/tools/send/services/test-data/send-tests.data.ts @@ -0,0 +1,79 @@ +import { EncString } from "../../../../platform/models/domain/enc-string"; +import { SendType } from "../../enums/send-type"; +import { SendTextApi } from "../../models/api/send-text.api"; +import { SendTextData } from "../../models/data/send-text.data"; +import { SendData } from "../../models/data/send.data"; +import { Send } from "../../models/domain/send"; +import { SendView } from "../../models/view/send.view"; + +export function testSendViewData(id: string, name: string) { + const data = new SendView({} as any); + data.id = id; + data.name = name; + data.disabled = false; + data.accessCount = 2; + data.accessId = "1"; + data.revisionDate = null; + data.expirationDate = null; + data.deletionDate = null; + data.notes = "Notes!!"; + data.key = null; + return data; +} + +export function createSendData(value: Partial<SendData> = {}) { + const defaultSendData: Partial<SendData> = { + id: "1", + name: "Test Send", + accessId: "123", + type: SendType.Text, + notes: "notes!", + file: null, + text: new SendTextData(new SendTextApi({ Text: "send text" })), + key: "key", + maxAccessCount: 12, + accessCount: 2, + revisionDate: "2024-09-04", + expirationDate: "2024-09-04", + deletionDate: "2024-09-04", + password: "password", + disabled: false, + hideEmail: false, + }; + + const testSend: any = {}; + for (const prop in defaultSendData) { + testSend[prop] = value[prop as keyof SendData] ?? defaultSendData[prop as keyof SendData]; + } + return testSend; +} + +export function testSendData(id: string, name: string) { + const data = new SendData({} as any); + data.id = id; + data.name = name; + data.disabled = false; + data.accessCount = 2; + data.accessId = "1"; + data.revisionDate = null; + data.expirationDate = null; + data.deletionDate = null; + data.notes = "Notes!!"; + data.key = null; + return data; +} + +export function testSend(id: string, name: string) { + const data = new Send({} as any); + data.id = id; + data.name = new EncString(name); + data.disabled = false; + data.accessCount = 2; + data.accessId = "1"; + data.revisionDate = null; + data.expirationDate = null; + data.deletionDate = null; + data.notes = new EncString("Notes!!"); + data.key = null; + return data; +} diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index bcd4bb98362..c3747247816 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -7,7 +7,7 @@ import { SearchService } from "../../abstractions/search.service"; import { AutofillSettingsService } from "../../autofill/services/autofill-settings.service"; import { DomainSettingsService } from "../../autofill/services/domain-settings.service"; import { UriMatchStrategy } from "../../models/domain/domain-service"; -import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "../../platform/abstractions/config/config.service"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; @@ -108,7 +108,7 @@ describe("Cipher Service", () => { const i18nService = mock<I18nService>(); const searchService = mock<SearchService>(); const encryptService = mock<EncryptService>(); - const configService = mock<ConfigServiceAbstraction>(); + const configService = mock<ConfigService>(); let cipherService: CipherService; let cipherObj: Cipher; diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 4293e56728c..4a6e96ead76 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -9,7 +9,7 @@ import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { ErrorResponse } from "../../models/response/error.response"; import { ListResponse } from "../../models/response/list.response"; import { View } from "../../models/view/view"; -import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "../../platform/abstractions/config/config.service"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; @@ -72,7 +72,7 @@ export class CipherService implements CipherServiceAbstraction { private autofillSettingsService: AutofillSettingsServiceAbstraction, private encryptService: EncryptService, private cipherFileUploadService: CipherFileUploadService, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, ) {} async getDecryptedCipherCache(): Promise<CipherView[]> { @@ -1387,7 +1387,6 @@ export class CipherService implements CipherServiceAbstraction { cipher.attachments = attachments; }), ]); - return cipher; } diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts b/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts index 2f76c5043af..9757e24d8fb 100644 --- a/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts +++ b/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts @@ -4,7 +4,7 @@ import { of } from "rxjs"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; -import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { Utils } from "../../../platform/misc/utils"; import { Fido2AuthenticatorError, @@ -30,7 +30,7 @@ const VaultUrl = "https://vault.bitwarden.com"; describe("FidoAuthenticatorService", () => { let authenticator!: MockProxy<Fido2AuthenticatorService>; - let configService!: MockProxy<ConfigServiceAbstraction>; + let configService!: MockProxy<ConfigService>; let authService!: MockProxy<AuthService>; let vaultSettingsService: MockProxy<VaultSettingsService>; let domainSettingsService: MockProxy<DomainSettingsService>; @@ -39,7 +39,7 @@ describe("FidoAuthenticatorService", () => { beforeEach(async () => { authenticator = mock<Fido2AuthenticatorService>(); - configService = mock<ConfigServiceAbstraction>(); + configService = mock<ConfigService>(); authService = mock<AuthService>(); vaultSettingsService = mock<VaultSettingsService>(); domainSettingsService = mock<DomainSettingsService>(); diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.ts b/libs/common/src/vault/services/fido2/fido2-client.service.ts index c725b226372..bfc8cbe915a 100644 --- a/libs/common/src/vault/services/fido2/fido2-client.service.ts +++ b/libs/common/src/vault/services/fido2/fido2-client.service.ts @@ -4,7 +4,7 @@ import { parse } from "tldts"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; -import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { Utils } from "../../../platform/misc/utils"; import { @@ -40,7 +40,7 @@ import { Fido2Utils } from "./fido2-utils"; export class Fido2ClientService implements Fido2ClientServiceAbstraction { constructor( private authenticator: Fido2AuthenticatorService, - private configService: ConfigServiceAbstraction, + private configService: ConfigService, private authService: AuthService, private vaultSettingsService: VaultSettingsService, private domainSettingsService: DomainSettingsService, diff --git a/libs/common/src/vault/services/sync/sync.service.ts b/libs/common/src/vault/services/sync/sync.service.ts index 3e8bd92a7ac..d4601d96210 100644 --- a/libs/common/src/vault/services/sync/sync.service.ts +++ b/libs/common/src/vault/services/sync/sync.service.ts @@ -244,7 +244,7 @@ export class SyncService implements SyncServiceAbstraction { this.syncStarted(); if (await this.stateService.getIsAuthenticated()) { try { - const localSend = this.sendService.get(notification.id); + const localSend = await firstValueFrom(this.sendService.get$(notification.id)); if ( (!isEdit && localSend == null) || (isEdit && localSend != null && localSend.revisionDate < notification.revisionDate) diff --git a/libs/common/test.setup.ts b/libs/common/test.setup.ts index c50c7ca227e..d857751b51b 100644 --- a/libs/common/test.setup.ts +++ b/libs/common/test.setup.ts @@ -1,6 +1,7 @@ import { webcrypto } from "crypto"; import { toEqualBuffer } from "./spec"; +import { toAlmostEqual } from "./spec/matchers/to-almost-equal"; Object.defineProperty(window, "crypto", { value: webcrypto, @@ -10,8 +11,15 @@ Object.defineProperty(window, "crypto", { expect.extend({ toEqualBuffer: toEqualBuffer, + toAlmostEqual: toAlmostEqual, }); export interface CustomMatchers<R = unknown> { toEqualBuffer(expected: Uint8Array | ArrayBuffer): R; + /** + * Matches the expected date within an optional ms precision + * @param expected The expected date + * @param msPrecision The optional precision in milliseconds + */ + toAlmostEqual(expected: Date, msPrecision?: number): R; } diff --git a/libs/components/src/icon/icon.stories.ts b/libs/components/src/icon/icon.stories.ts index 95bf457517d..54cdd7928cd 100644 --- a/libs/components/src/icon/icon.stories.ts +++ b/libs/components/src/icon/icon.stories.ts @@ -1,31 +1,24 @@ import { Meta, StoryObj } from "@storybook/angular"; import { BitIconComponent } from "./icon.component"; +import * as GenericIcons from "./icons"; export default { title: "Component Library/Icon", component: BitIconComponent, - args: { - icon: "reportExposedPasswords", - }, } as Meta; type Story = StoryObj<BitIconComponent>; -export const ReportExposedPasswords: Story = { - render: (args) => ({ - props: args, - template: ` - <div class="tw-bg-primary-500 tw-p-5"> - <bit-icon [icon]="icon" class="tw-text-primary-300"></bit-icon> - </div> - `, - }), -}; - -export const UnknownIcon: Story = { - ...ReportExposedPasswords, +export const Default: Story = { args: { - icon: "unknown" as any, + icon: GenericIcons.NoAccess, + }, + argTypes: { + icon: { + options: Object.keys(GenericIcons), + mapping: GenericIcons, + control: { type: "select" }, + }, }, }; diff --git a/libs/importer/spec/test-data/bitwarden-json/cipher-with-collections.json.ts b/libs/importer/spec/test-data/bitwarden-json/cipher-with-collections.json.ts new file mode 100644 index 00000000000..ef92ea79849 --- /dev/null +++ b/libs/importer/spec/test-data/bitwarden-json/cipher-with-collections.json.ts @@ -0,0 +1,37 @@ +export const cipherWithCollections = `{ + "encrypted": false, + "collections": [ + { + "id": "8e3f5ba1-3e87-4ee8-8da9-b1180099ff9f", + "organizationId": "c6181652-66eb-4cd9-a7f2-b02a00e12352", + "name": "asdf", + "externalId": null + } + ], + "items": [ + { + "passwordHistory": null, + "revisionDate": "2024-02-16T09:20:48.383Z", + "creationDate": "2024-02-16T09:20:48.383Z", + "deletedDate": null, + "id": "f761a968-4b0f-4090-a568-b118009a07b5", + "organizationId": "c6181652-66eb-4cd9-a7f2-b02a00e12352", + "folderId": null, + "type": 1, + "reprompt": 0, + "name": "asdf123", + "notes": null, + "favorite": false, + "login": { + "fido2Credentials": [], + "uris": [], + "username": null, + "password": null, + "totp": null + }, + "collectionIds": [ + "8e3f5ba1-3e87-4ee8-8da9-b1180099ff9f" + ] + } + ] + }`; diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index a95b74d792c..eb21f384b56 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -6,6 +6,7 @@ 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 { 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"; @@ -207,5 +208,60 @@ describe("ImportService", () => { await expect(setImportTargetMethod).rejects.toThrow("Error assigning target folder"); }); + + it("passing importTarget, collectionRelationship has the expected values", async () => { + collectionService.getAllDecrypted.mockResolvedValue([ + mockImportTargetCollection, + mockCollection1, + mockCollection2, + ]); + + importResult.ciphers.push(createCipher({ name: "cipher1" })); + importResult.ciphers.push(createCipher({ name: "cipher2" })); + importResult.collectionRelationships.push([0, 0]); + importResult.collections.push(mockCollection1); + importResult.collections.push(mockCollection2); + + await importService["setImportTarget"]( + importResult, + organizationId, + mockImportTargetCollection, + ); + expect(importResult.collectionRelationships.length).toEqual(2); + expect(importResult.collectionRelationships[0]).toEqual([1, 0]); + expect(importResult.collectionRelationships[1]).toEqual([0, 1]); + }); + + it("passing importTarget, folderRelationship has the expected values", async () => { + folderService.getAllDecryptedFromState.mockResolvedValue([ + mockImportTargetFolder, + mockFolder1, + mockFolder2, + ]); + + importResult.folders.push(mockFolder1); + importResult.folders.push(mockFolder2); + + importResult.ciphers.push(createCipher({ name: "cipher1", folderId: mockFolder1.id })); + importResult.ciphers.push(createCipher({ name: "cipher2" })); + importResult.folderRelationships.push([0, 0]); + + await importService["setImportTarget"](importResult, "", mockImportTargetFolder); + expect(importResult.folderRelationships.length).toEqual(2); + expect(importResult.folderRelationships[0]).toEqual([1, 0]); + expect(importResult.folderRelationships[1]).toEqual([0, 1]); + }); }); }); + +function createCipher(options: Partial<CipherView> = {}) { + const cipher = new CipherView(); + + cipher.name; + cipher.type = options.type; + cipher.folderId = options.folderId; + cipher.collectionIds = options.collectionIds; + cipher.organizationId = options.organizationId; + + return cipher; +} diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index a6fd233dcf6..62961a77c4c 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -437,8 +437,10 @@ export class ImportService implements ImportServiceAbstraction { const noCollectionRelationShips: [number, number][] = []; importResult.ciphers.forEach((c, index) => { - if (!Array.isArray(c.collectionIds) || c.collectionIds.length == 0) { - c.collectionIds = [importTarget.id]; + if ( + !Array.isArray(importResult.collectionRelationships) || + !importResult.collectionRelationships.some(([cipherPos]) => cipherPos === index) + ) { noCollectionRelationShips.push([index, 0]); } }); diff --git a/libs/angular/src/tools/export/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts similarity index 98% rename from libs/angular/src/tools/export/components/export.component.ts rename to libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index 400071e59cc..ce478db19a7 100644 --- a/libs/angular/src/tools/export/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -2,6 +2,7 @@ import { Directive, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from "@ import { UntypedFormBuilder, Validators } from "@angular/forms"; import { map, merge, Observable, startWith, Subject, takeUntil } from "rxjs"; +import { PasswordStrengthComponent } from "@bitwarden/angular/tools/password-strength/password-strength.component"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -18,8 +19,6 @@ import { EncryptedExportType } from "@bitwarden/common/tools/enums/encrypted-exp import { DialogService } from "@bitwarden/components"; import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; -import { PasswordStrengthComponent } from "../../password-strength/password-strength.component"; - @Directive() export class ExportComponent implements OnInit, OnDestroy { @Output() onSaved = new EventEmitter(); diff --git a/libs/tools/export/vault-export/vault-export-ui/src/index.ts b/libs/tools/export/vault-export/vault-export-ui/src/index.ts index 4165ee4558a..919bc8b38e5 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/index.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/index.ts @@ -1 +1,2 @@ +export { ExportComponent } from "./components/export.component"; export { ExportScopeCalloutComponent } from "./components/export-scope-callout.component"; diff --git a/package-lock.json b/package-lock.json index b8998635689..ef239049675 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "bufferutil": "4.0.8", "chalk": "4.1.2", "commander": "11.1.0", - "core-js": "3.34.0", + "core-js": "3.36.1", "duo_web_sdk": "github:duosecurity/duo_web_sdk", "form-data": "4.0.0", "https-proxy-agent": "7.0.2", @@ -67,7 +67,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.13", + "tldts": "6.1.16", "utf-8-validate": "6.0.3", "zone.js": "0.13.3", "zxcvbn": "4.4.2" @@ -117,16 +117,16 @@ "@types/react": "16.14.57", "@types/retry": "0.12.5", "@types/zxcvbn": "4.4.4", - "@typescript-eslint/eslint-plugin": "6.21.0", - "@typescript-eslint/parser": "6.21.0", + "@typescript-eslint/eslint-plugin": "7.4.0", + "@typescript-eslint/parser": "7.4.0", "@webcomponents/custom-elements": "1.6.0", "autoprefixer": "10.4.18", "base64-loader": "1.0.0", "chromatic": "10.9.6", "concurrently": "8.2.2", - "copy-webpack-plugin": "11.0.0", + "copy-webpack-plugin": "12.0.2", "cross-env": "7.0.3", - "css-loader": "6.8.1", + "css-loader": "6.10.0", "electron": "28.2.8", "electron-builder": "24.13.3", "electron-log": "5.0.1", @@ -149,7 +149,7 @@ "gulp-zip": "6.0.0", "html-loader": "4.2.0", "html-webpack-injector": "1.1.4", - "html-webpack-plugin": "5.5.4", + "html-webpack-plugin": "5.6.0", "husky": "9.0.11", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.5", @@ -159,9 +159,9 @@ "node-ipc": "9.2.1", "pkg": "5.8.1", "postcss": "8.4.35", - "postcss-loader": "7.3.4", + "postcss-loader": "8.1.1", "prettier": "3.2.2", - "prettier-plugin-tailwindcss": "0.5.12", + "prettier-plugin-tailwindcss": "0.5.13", "process": "0.11.10", "react": "18.2.0", "react-dom": "18.2.0", @@ -183,21 +183,21 @@ "wait-on": "7.2.0", "webpack": "5.89.0", "webpack-cli": "5.1.4", - "webpack-dev-server": "4.15.1", + "webpack-dev-server": "5.0.4", "webpack-node-externals": "3.0.0" }, "engines": { - "node": "~18", + "node": "^18.18.0", "npm": "~9" } }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2024.3.0" + "version": "2024.3.1" }, "apps/cli": { "name": "@bitwarden/cli", - "version": "2024.3.0", + "version": "2024.3.1", "license": "GPL-3.0-only", "dependencies": { "@koa/multer": "3.0.2", @@ -224,7 +224,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.13", + "tldts": "6.1.16", "zxcvbn": "4.4.2" }, "bin": { @@ -233,7 +233,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2024.3.1", + "version": "2024.3.2", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -263,7 +263,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2024.3.0" + "version": "2024.3.1" }, "libs/admin-console": { "name": "@bitwarden/admin-console", @@ -614,6 +614,95 @@ "postcss": "^8.1.0" } }, + "node_modules/@angular-devkit/build-angular/node_modules/copy-webpack-plugin": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", + "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", + "dev": true, + "dependencies": { + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.1", + "globby": "^13.1.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/copy-webpack-plugin/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/copy-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/@angular-devkit/build-angular/node_modules/copy-webpack-plugin/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -640,6 +729,32 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/css-loader": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz", + "integrity": "sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==", + "dev": true, + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.21", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.3", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -678,6 +793,45 @@ "node": ">=8.6.0" } }, + "node_modules/@angular-devkit/build-angular/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/globby": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", + "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", + "dev": true, + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.3.0", + "ignore": "^5.2.4", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -726,6 +880,15 @@ "tslib": "^2.1.0" } }, + "node_modules/@angular-devkit/build-angular/node_modules/ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -794,6 +957,21 @@ "webpack": "^5.0.0" } }, + "node_modules/@angular-devkit/build-angular/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/sass": { "version": "1.64.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.64.1.tgz", @@ -866,6 +1044,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/@angular-devkit/build-angular/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/webpack": { "version": "5.88.2", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", @@ -913,6 +1103,162 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack-dev-server": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", + "integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==", + "dev": true, + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.5", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "launch-editor": "^2.6.0", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.1", + "ws": "^8.13.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack-dev-server/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack-dev-server/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack-dev-server/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack-dev-server/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/webpack-dev-server/node_modules/webpack-dev-middleware": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", + "dev": true, + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/ws": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@angular-devkit/build-webpack": { "version": "0.1602.11", "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1602.11.tgz", @@ -7250,6 +7596,18 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", @@ -10845,9 +11203,9 @@ } }, "node_modules/@types/bonjour": { - "version": "3.5.10", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.10.tgz", - "integrity": "sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw==", + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", "dev": true, "dependencies": { "@types/node": "*" @@ -10885,9 +11243,9 @@ } }, "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz", - "integrity": "sha512-4x5FkPpLipqwthjPsF7ZRbOv3uoLUFkTA9G9v583qi4pACvq0uTELrB8OLUzPWUI4IJIyvM85vzkV1nyiI2Lig==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", "dev": true, "dependencies": { "@types/express-serve-static-core": "*", @@ -10993,9 +11351,9 @@ "dev": true }, "node_modules/@types/express": { - "version": "4.17.17", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", - "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", "dev": true, "dependencies": { "@types/body-parser": "*", @@ -11487,20 +11845,21 @@ } }, "node_modules/@types/serve-index": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", "dev": true, "dependencies": { "@types/express": "*" } }, "node_modules/@types/serve-static": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.1.tgz", - "integrity": "sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", + "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", "dev": true, "dependencies": { + "@types/http-errors": "*", "@types/mime": "*", "@types/node": "*" } @@ -11512,9 +11871,9 @@ "dev": true }, "node_modules/@types/sockjs": { - "version": "0.3.33", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", - "integrity": "sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==", + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", "dev": true, "dependencies": { "@types/node": "*" @@ -11582,6 +11941,15 @@ "integrity": "sha512-D0HJET2/UY6k9L6y3f5BL+IDxZmPkYmPT4+qBrRdmRLYRuV0qNKizMgTvYxXZYn+36zjPeoDZAEYBCM6XB+gww==", "dev": true }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", @@ -11614,16 +11982,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.4.0.tgz", + "integrity": "sha512-yHMQ/oFaM7HZdVrVm/M2WHaNPgyuJH4WelkSVEWSSsir34kxW2kDJCxlXRhhGWEsMN0WAW/vLpKfKVcm8k+MPw==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/scope-manager": "7.4.0", + "@typescript-eslint/type-utils": "7.4.0", + "@typescript-eslint/utils": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -11632,15 +12000,15 @@ "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -11649,16 +12017,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.4.0.tgz", + "integrity": "sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -11666,25 +12034,25 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", - "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.4.0.tgz", + "integrity": "sha512-247ETeHgr9WTRMqHbbQdzwzhuyaJ8dPTuyuUEMANqzMRB1rj/9qFIuIXK7l0FX9i9FXbHeBQl/4uz6mYuCE7Aw==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/typescript-estree": "7.4.0", + "@typescript-eslint/utils": "7.4.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -11693,12 +12061,12 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.4.0.tgz", + "integrity": "sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw==", "dev": true, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -11706,13 +12074,13 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.4.0.tgz", + "integrity": "sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -11721,7 +12089,7 @@ "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -11734,41 +12102,41 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", - "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.4.0.tgz", + "integrity": "sha512-NQt9QLM4Tt8qrlBVY9lkMYzfYtNz8/6qwZg8pI3cMGlPnj6mOpRxxAm7BMJN9K0AiY+1BwJ5lVC650YJqYOuNg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/scope-manager": "7.4.0", + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/typescript-estree": "7.4.0", "semver": "^7.5.4" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.4.0.tgz", + "integrity": "sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/types": "7.4.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -11867,26 +12235,26 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.4.0.tgz", + "integrity": "sha512-ZvKHxHLusweEUVwrGRXXUVzFgnWhigo4JurEj0dGF1tbcGh6buL+ejDdjxOQxv6ytcY1uhun1p2sm8iWStlgLQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/scope-manager": "7.4.0", + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/typescript-estree": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0", "debug": "^4.3.4" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -11895,16 +12263,16 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.4.0.tgz", + "integrity": "sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -11912,12 +12280,12 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.4.0.tgz", + "integrity": "sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw==", "dev": true, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -11925,13 +12293,13 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.4.0.tgz", + "integrity": "sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -11940,7 +12308,7 @@ "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -11953,16 +12321,16 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.4.0.tgz", + "integrity": "sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/types": "7.4.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -14757,23 +15125,15 @@ } }, "node_modules/bonjour-service": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.1.1.tgz", - "integrity": "sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.2.1.tgz", + "integrity": "sha512-oSzCS2zV14bh2kji6vNe7vrpJYCHGvcZnlffFQ1MEoX/WOeQ/teD8SYWKR942OI3INjq8OMNJlbPK5LLLUxFDw==", "dev": true, "dependencies": { - "array-flatten": "^2.1.2", - "dns-equal": "^1.0.0", "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, - "node_modules/bonjour-service/node_modules/array-flatten": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", - "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", - "dev": true - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -15171,6 +15531,21 @@ "semver": "^7.0.0" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -16516,20 +16891,20 @@ "integrity": "sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==" }, "node_modules/copy-webpack-plugin": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", - "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", + "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", "dev": true, "dependencies": { - "fast-glob": "^3.2.11", + "fast-glob": "^3.3.2", "glob-parent": "^6.0.1", - "globby": "^13.1.1", + "globby": "^14.0.0", "normalize-path": "^3.0.0", - "schema-utils": "^4.0.0", - "serialize-javascript": "^6.0.0" + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2" }, "engines": { - "node": ">= 14.15.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", @@ -16552,28 +16927,29 @@ } }, "node_modules/copy-webpack-plugin/node_modules/globby": { - "version": "13.1.4", - "resolved": "https://registry.npmjs.org/globby/-/globby-13.1.4.tgz", - "integrity": "sha512-iui/IiiW+QrJ1X1hKH5qwlMQyv34wJAYwH1vrf8b9kBA4sNiif3gKsMHa+BrdnOpEudWjpotfa7LrTzB1ERS/g==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.1.tgz", + "integrity": "sha512-jOMLD2Z7MAhyG8aJpNOpmziMOP4rPLcc95oQPKXBazW82z+CEgPFBQvEpRUa1KeIMUJo4Wsm+q6uzO/Q/4BksQ==", "dev": true, "dependencies": { - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.11", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^4.0.0" + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.2", + "ignore": "^5.2.4", + "path-type": "^5.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.1.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/copy-webpack-plugin/node_modules/slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "node_modules/copy-webpack-plugin/node_modules/path-type": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz", + "integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==", "dev": true, "engines": { "node": ">=12" @@ -16582,10 +16958,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/copy-webpack-plugin/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/core-js": { - "version": "3.34.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.34.0.tgz", - "integrity": "sha512-aDdvlDder8QmY91H88GzNi9EtQi2TjvQhpCX6B1v/dAZHU1AuLgHvRh54RiOerpEhEW46Tkf+vgAViB/CWC0ag==", + "version": "3.36.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.36.1.tgz", + "integrity": "sha512-BTvUrwxVBezj5SZ3f10ImnX2oRByMxql3EimVqMysepbC9EeMUOpLwdy6Eoili2x6E4kf+ZUB5k/+Jv55alPfA==", "hasInstallScript": true, "funding": { "type": "opencollective", @@ -16782,19 +17170,19 @@ } }, "node_modules/css-loader": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz", - "integrity": "sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.10.0.tgz", + "integrity": "sha512-LTSA/jWbwdMlk+rhmElbDR2vbtQoTBPr7fkJE+mxrHj+7ru0hUmHafDRzWIjIHTwpitWVaqY2/UWGRca3yUgRw==", "dev": true, "dependencies": { "icss-utils": "^5.1.0", - "postcss": "^8.4.21", + "postcss": "^8.4.33", "postcss-modules-extract-imports": "^3.0.0", - "postcss-modules-local-by-default": "^4.0.3", - "postcss-modules-scope": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.4", + "postcss-modules-scope": "^3.1.1", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", - "semver": "^7.3.8" + "semver": "^7.5.4" }, "engines": { "node": ">= 12.13.0" @@ -16804,7 +17192,48 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { + "@rspack/core": "0.x || 1.x", "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/postcss-modules-local-by-default": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.4.tgz", + "integrity": "sha512-L4QzMnOdVwRm1Qb8m4x8jsZzKAaPAgrUF1r/hjDR2Xj7R+8Zsf97jAlSQzWtKx5YNiNGN8QxmPFIc/sh+RQl+Q==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/css-loader/node_modules/postcss-modules-scope": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.1.1.tgz", + "integrity": "sha512-uZgqzdTleelWjzJY+Fhti6F3C9iF1JR/dODLs/JDefozYcKTBCdD8BIl6nNPbTbcLnGrk56hzwZC2DaGNvYjzA==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" } }, "node_modules/css-select": { @@ -17094,6 +17523,22 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "dev": true, + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/default-browser-id": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", @@ -17110,6 +17555,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/default-browser/node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/default-compare": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/default-compare/-/default-compare-1.0.0.tgz", @@ -17536,12 +17993,6 @@ "dev": true, "optional": true }, - "node_modules/dns-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", - "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==", - "dev": true - }, "node_modules/dns-packet": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.0.tgz", @@ -22361,9 +22812,9 @@ "dev": true }, "node_modules/html-webpack-plugin": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.4.tgz", - "integrity": "sha512-3wNSaVVxdxcu0jd4FpQFoICdqgxs4zIQQvj+2yQKFfBOnLETQ6X5CDWdeasuGlSsooFlMkEioWDTqBv1wvw5Iw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz", + "integrity": "sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw==", "dev": true, "dependencies": { "@types/html-minifier-terser": "^6.0.0", @@ -22380,7 +22831,16 @@ "url": "https://opencollective.com/html-webpack-plugin" }, "peerDependencies": { + "@rspack/core": "0.x || 1.x", "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, "node_modules/html-webpack-plugin/node_modules/commander": { @@ -23458,6 +23918,39 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -23518,6 +24011,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-network-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz", + "integrity": "sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -25581,13 +26086,13 @@ } }, "node_modules/launch-editor": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.0.tgz", - "integrity": "sha512-JpDCcQnyAAzZZaZ7vEiSqL690w7dAEyLao+KC96zBplnYbJS7TYNjvM3M7y3dGz+v7aIsJk3hllWuc0kWAjyRQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.1.tgz", + "integrity": "sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==", "dev": true, "dependencies": { "picocolors": "^1.0.0", - "shell-quote": "^1.7.3" + "shell-quote": "^1.8.1" } }, "node_modules/lazy-universal-dotenv": { @@ -30996,25 +31501,34 @@ } }, "node_modules/postcss-loader": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.4.tgz", - "integrity": "sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", + "integrity": "sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==", "dev": true, "dependencies": { - "cosmiconfig": "^8.3.5", + "cosmiconfig": "^9.0.0", "jiti": "^1.20.0", "semver": "^7.5.4" }, "engines": { - "node": ">= 14.15.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { + "@rspack/core": "0.x || 1.x", "postcss": "^7.0.0 || ^8.0.1", "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, "node_modules/postcss-loader/node_modules/argparse": { @@ -31024,15 +31538,15 @@ "dev": true }, "node_modules/postcss-loader/node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "dependencies": { + "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" + "parse-json": "^5.2.0" }, "engines": { "node": ">=14" @@ -31209,9 +31723,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.5.12", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.12.tgz", - "integrity": "sha512-o74kiDBVE73oHW+pdkFSluHBL3cYEvru5YgEqNkBMFF7Cjv+w1vI565lTlfoJT4VLWDe0FMtZ7FkE/7a4pMXSQ==", + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.13.tgz", + "integrity": "sha512-2tPWHCFNC+WRjAC4SIWQNSOdcL1NNkydXim8w7TDqlZi+/ulZYz2OouAI6qMtkggnPt7lGamboj6LcTMwcCvoQ==", "dev": true, "engines": { "node": ">=14.21.3" @@ -31221,6 +31735,7 @@ "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig-melody": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", @@ -31246,6 +31761,9 @@ "@trivago/prettier-plugin-sort-imports": { "optional": true }, + "@zackad/prettier-plugin-twig-melody": { + "optional": true + }, "prettier-plugin-astro": { "optional": true }, @@ -31275,9 +31793,6 @@ }, "prettier-plugin-svelte": { "optional": true - }, - "prettier-plugin-twig-melody": { - "optional": true } } }, @@ -33193,6 +33708,18 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==" }, + "node_modules/run-applescript": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -33423,9 +33950,9 @@ } }, "node_modules/schema-utils": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.1.tgz", - "integrity": "sha512-lELhBAAly9NowEsX0yZBlw9ahZG+sK/1RJ21EpzdYHKEs13Vku3LJ+MIPhh4sMs0oCCeufZQEQbMekiA4vuVIQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", "dev": true, "dependencies": { "@types/json-schema": "^7.0.9", @@ -33463,11 +33990,12 @@ "dev": true }, "node_modules/selfsigned": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.1.1.tgz", - "integrity": "sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", "dev": true, "dependencies": { + "@types/node-forge": "^1.3.0", "node-forge": "^1" }, "engines": { @@ -33626,9 +34154,9 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "dependencies": { "randombytes": "^2.1.0" @@ -36001,20 +36529,20 @@ "dev": true }, "node_modules/tldts": { - "version": "6.1.13", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.13.tgz", - "integrity": "sha512-+GxHFKVHvUTg2ieNPTx3b/NpZbgJSTZEDdI4cJzTjVYDuxijeHi1tt7CHHsMjLqyc+T50VVgWs3LIb2LrXOzxw==", + "version": "6.1.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.16.tgz", + "integrity": "sha512-X6VrQzW4RymhI1kBRvrWzYlRLXTftZpi7/s/9ZlDILA04yM2lNX7mBvkzDib9L4uSymHt8mBbeaielZMdsAkfQ==", "dependencies": { - "tldts-core": "^6.1.13" + "tldts-core": "^6.1.16" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.13", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.13.tgz", - "integrity": "sha512-M1XP4D13YtXARKroULnLsKKuI1NCRAbJmUGGoXqWinajIDOhTeJf/trYUyBoLVx1/Nx1KBKxCrlW57ZW9cMHAA==" + "version": "6.1.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.16.tgz", + "integrity": "sha512-rxnuCux+zn3hMF57nBzr1m1qGZH7Od2ErbDZjVm04fk76cEynTg3zqvHjx5BsBl8lvRTjpzIhsEGMHDH/Hr2Vw==" }, "node_modules/tmp": { "version": "0.0.33", @@ -36882,6 +37410,18 @@ "tiny-inflate": "^1.0.0" } }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unified": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", @@ -37914,54 +38454,54 @@ } }, "node_modules/webpack-dev-server": { - "version": "4.15.1", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", - "integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.0.4.tgz", + "integrity": "sha512-dljXhUgx3HqKP2d8J/fUMvhxGhzjeNVarDLcbO/EWMSgRizDkxHQDZQaLFL5VJY9tRBj2Gz+rvCEYYvhbqPHNA==", "dev": true, "dependencies": { - "@types/bonjour": "^3.5.9", - "@types/connect-history-api-fallback": "^1.3.5", - "@types/express": "^4.17.13", - "@types/serve-index": "^1.9.1", - "@types/serve-static": "^1.13.10", - "@types/sockjs": "^0.3.33", - "@types/ws": "^8.5.5", + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^4.17.21", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.0.11", - "chokidar": "^3.5.3", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", "colorette": "^2.0.10", "compression": "^1.7.4", "connect-history-api-fallback": "^2.0.0", "default-gateway": "^6.0.3", "express": "^4.17.3", "graceful-fs": "^4.2.6", - "html-entities": "^2.3.2", + "html-entities": "^2.4.0", "http-proxy-middleware": "^2.0.3", - "ipaddr.js": "^2.0.1", - "launch-editor": "^2.6.0", - "open": "^8.0.9", - "p-retry": "^4.5.0", - "rimraf": "^3.0.2", - "schema-utils": "^4.0.0", - "selfsigned": "^2.1.1", + "ipaddr.js": "^2.1.0", + "launch-editor": "^2.6.1", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "rimraf": "^5.0.5", + "schema-utils": "^4.2.0", + "selfsigned": "^2.4.1", "serve-index": "^1.9.1", "sockjs": "^0.3.24", "spdy": "^4.0.2", - "webpack-dev-middleware": "^5.3.1", - "ws": "^8.13.0" + "webpack-dev-middleware": "^7.1.0", + "ws": "^8.16.0" }, "bin": { "webpack-dev-server": "bin/webpack-dev-server.js" }, "engines": { - "node": ">= 12.13.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^4.37.0 || ^5.0.0" + "webpack": "^5.0.0" }, "peerDependenciesMeta": { "webpack": { @@ -37972,33 +38512,46 @@ } } }, - "node_modules/webpack-dev-server/node_modules/@types/ws": { - "version": "8.5.5", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.5.tgz", - "integrity": "sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } + "node_modules/webpack-dev-server/node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true }, - "node_modules/webpack-dev-server/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "node_modules/webpack-dev-server/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" }, "engines": { - "node": "*" + "node": ">= 8.10.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/webpack-dev-server/node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/webpack-dev-server/node_modules/ipaddr.js": { @@ -38010,48 +38563,114 @@ "node": ">= 10" } }, - "node_modules/webpack-dev-server/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "node_modules/webpack-dev-server/node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", "dev": true, "dependencies": { - "glob": "^7.1.3" + "is-inside-container": "^1.0.0" }, - "bin": { - "rimraf": "bin.js" + "engines": { + "node": ">=16" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/memfs": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.8.0.tgz", + "integrity": "sha512-fcs7trFxZlOMadmTw5nyfOwS3il9pr3y+6xzLfXNwmuR/D0i4wz6rJURxArAbcJDGalbpbMvQ/IFI0NojRZgRg==", + "dev": true, + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, + "node_modules/webpack-dev-server/node_modules/open": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", + "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", + "dev": true, + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/p-retry": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.0.tgz", + "integrity": "sha512-JA6nkq6hKyWLLasXQXUrO4z8BUZGUt/LjlJxx8Gb2+2ntodU/SS63YZ8b0LUTbQ8ZB9iwOfhEPhg4ykKnn2KsA==", + "dev": true, + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "engines": { + "node": ">= 4" } }, "node_modules/webpack-dev-server/node_modules/webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.1.1.tgz", + "integrity": "sha512-NmRVq4AvRQs66dFWyDR4GsFDJggtSi2Yn38MXLk0nffgF9n/AIP4TFBg2TQKYaRAN4sHuKOTiz9BnNCENDLEVA==", "dev": true, "dependencies": { "colorette": "^2.0.10", - "memfs": "^3.4.3", + "memfs": "^4.6.0", "mime-types": "^2.1.31", + "on-finished": "^2.4.1", "range-parser": "^1.2.1", "schema-utils": "^4.0.0" }, "engines": { - "node": ">= 12.13.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } } }, "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", "dev": true, "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index d1276287c1c..88ba36e3c0f 100644 --- a/package.json +++ b/package.json @@ -78,16 +78,16 @@ "@types/react": "16.14.57", "@types/retry": "0.12.5", "@types/zxcvbn": "4.4.4", - "@typescript-eslint/eslint-plugin": "6.21.0", - "@typescript-eslint/parser": "6.21.0", + "@typescript-eslint/eslint-plugin": "7.4.0", + "@typescript-eslint/parser": "7.4.0", "@webcomponents/custom-elements": "1.6.0", "autoprefixer": "10.4.18", "base64-loader": "1.0.0", "chromatic": "10.9.6", "concurrently": "8.2.2", - "copy-webpack-plugin": "11.0.0", + "copy-webpack-plugin": "12.0.2", "cross-env": "7.0.3", - "css-loader": "6.8.1", + "css-loader": "6.10.0", "electron": "28.2.8", "electron-builder": "24.13.3", "electron-log": "5.0.1", @@ -110,7 +110,7 @@ "gulp-zip": "6.0.0", "html-loader": "4.2.0", "html-webpack-injector": "1.1.4", - "html-webpack-plugin": "5.5.4", + "html-webpack-plugin": "5.6.0", "husky": "9.0.11", "jest-junit": "16.0.0", "jest-mock-extended": "3.0.5", @@ -120,9 +120,9 @@ "node-ipc": "9.2.1", "pkg": "5.8.1", "postcss": "8.4.35", - "postcss-loader": "7.3.4", + "postcss-loader": "8.1.1", "prettier": "3.2.2", - "prettier-plugin-tailwindcss": "0.5.12", + "prettier-plugin-tailwindcss": "0.5.13", "process": "0.11.10", "react": "18.2.0", "react-dom": "18.2.0", @@ -144,7 +144,7 @@ "wait-on": "7.2.0", "webpack": "5.89.0", "webpack-cli": "5.1.4", - "webpack-dev-server": "4.15.1", + "webpack-dev-server": "5.0.4", "webpack-node-externals": "3.0.0" }, "dependencies": { @@ -171,7 +171,7 @@ "bufferutil": "4.0.8", "chalk": "4.1.2", "commander": "11.1.0", - "core-js": "3.34.0", + "core-js": "3.36.1", "duo_web_sdk": "github:duosecurity/duo_web_sdk", "form-data": "4.0.0", "https-proxy-agent": "7.0.2", @@ -200,7 +200,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.13", + "tldts": "6.1.16", "utf-8-validate": "6.0.3", "zone.js": "0.13.3", "zxcvbn": "4.4.2"