diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 000000000000..ff3f1ffc4c7c --- /dev/null +++ b/.browserslistrc @@ -0,0 +1 @@ +> 0.5%, last 3 major versions, Firefox ESR, not dead diff --git a/.eslintrc.json b/.eslintrc.json index ce9c4a33426c..1a3849aee30b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -97,6 +97,11 @@ // Node "./libs/node/**/*", + //Generator + "./libs/tools/generator/components/**/*", + "./libs/tools/generator/core/**/*", + "./libs/tools/generator/extensions/**/*", + // Import/export "./libs/importer/**/*", "./libs/tools/export/vault-export/vault-export-core/**/*" @@ -182,6 +187,33 @@ ] } }, + { + "files": ["libs/tools/generator/components/src/**/*.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { "patterns": ["@bitwarden/generator-components/*", "src/**/*"] } + ] + } + }, + { + "files": ["libs/tools/generator/core/src/**/*.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { "patterns": ["@bitwarden/generator-core/*", "src/**/*"] } + ] + } + }, + { + "files": ["libs/tools/generator/extensions/src/**/*.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { "patterns": ["@bitwarden/generator-extensions/*", "src/**/*"] } + ] + } + }, { "files": ["libs/tools/export/vault-export/vault-export-core/src/**/*.ts"], "rules": { diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 47ebaf5189c3..5e333b3b58ae 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -13,6 +13,7 @@ on: - '!*.md' - '!*.txt' - '.github/workflows/build-cli.yml' + - 'bitwarden_license/bit-cli/**' push: branches: - 'main' @@ -25,6 +26,7 @@ on: - '!*.md' - '!*.txt' - '.github/workflows/build-cli.yml' + - 'bitwarden_license/bit-cli/**' workflow_dispatch: inputs: {} @@ -65,7 +67,7 @@ jobs: os: [ { base: "linux", distro: "ubuntu-22.04" }, - { base: "mac", distro: "macos-11" } + { base: "mac", distro: "macos-13" } ] license_type: [ diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index e73f882bb40f..caf3d8cda51f 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -152,7 +152,7 @@ jobs: - name: Set up environment run: | sudo apt-get update - sudo apt-get -y install pkg-config libxss-dev libsecret-1-dev rpm musl-dev musl-tools + sudo apt-get -y install pkg-config libxss-dev libsecret-1-dev rpm musl-dev musl-tools libssl-dev - name: Set up Snap run: sudo snap install snapcraft --classic @@ -444,10 +444,7 @@ jobs: macos-build: name: MacOS Build - # 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 + runs-on: macos-13 needs: setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} @@ -605,10 +602,7 @@ jobs: macos-package-github: name: MacOS Package GitHub Release Assets - # 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 + runs-on: macos-13 needs: - browser-build - macos-build @@ -814,10 +808,7 @@ jobs: macos-package-mas: name: MacOS Package Prod Release Asset - # 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 + runs-on: macos-13 needs: - browser-build - macos-build @@ -1014,11 +1005,7 @@ jobs: macos-package-dev: name: MacOS Package Dev Release Asset - if: false # We need to look into how code signing works for dev - # 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 + runs-on: macos-13 needs: - browser-build - macos-build @@ -1188,14 +1175,15 @@ jobs: APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - name: Zip masdev asset - working-directory: ./dist/mas-dev-universal - run: zip -r Bitwarden-${{ env.PACKAGE_VERSION }}-masdev-universal.zip Bitwarden.app + run: | + cd dist/mas-dev-universal + zip -r Bitwarden-${{ env._PACKAGE_VERSION }}-masdev-universal.zip Bitwarden.app - name: Upload masdev artifact uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-masdev-universal.zip - path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-masdev-universal.zip + path: apps/desktop/dist/mas-dev-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-masdev-universal.zip if-no-files-found: error diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index 46f4ffad57da..0013234faa35 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -393,10 +393,7 @@ jobs: macos-build: name: MacOS Build - # 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 + runs-on: macos-13 needs: setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }} @@ -457,7 +454,7 @@ jobs: az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 - + az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 @@ -525,10 +522,7 @@ jobs: macos-package-github: name: MacOS Package GitHub Release Assets - # 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 + runs-on: macos-13 needs: - setup - macos-build @@ -596,7 +590,7 @@ jobs: az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 - + az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 @@ -665,7 +659,7 @@ jobs: - name: Download artifact from hotfix-rc if: github.ref == 'refs/heads/hotfix-rc' - uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 + uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-browser.yml workflow_conclusion: success @@ -674,7 +668,7 @@ jobs: - name: Download artifact from rc if: github.ref == 'refs/heads/rc' - uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 + uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-browser.yml workflow_conclusion: success @@ -683,7 +677,7 @@ jobs: - name: Download artifacts from main if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }} - uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 + uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-browser.yml workflow_conclusion: success @@ -738,10 +732,7 @@ jobs: macos-package-mas: name: MacOS Package Prod Release Asset - # 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 + runs-on: macos-13 needs: - setup - macos-build @@ -804,7 +795,7 @@ jobs: az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 - + az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 @@ -873,7 +864,7 @@ jobs: - name: Download artifact from hotfix-rc if: github.ref == 'refs/heads/hotfix-rc' - uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 + uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-browser.yml workflow_conclusion: success @@ -882,7 +873,7 @@ jobs: - name: Download artifact from rc if: github.ref == 'refs/heads/rc' - uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 + uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-browser.yml workflow_conclusion: success @@ -891,7 +882,7 @@ jobs: - name: Download artifact from main if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }} - uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 + uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-browser.yml workflow_conclusion: success diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c650d8a6224..12649b91ea92 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,10 +48,12 @@ jobs: # Tests in apps/ are typechecked when their app is built, so we just do it here for libs/ # See https://bitwarden.atlassian.net/browse/EC-497 - name: Run typechecking - run: npm run test:types --coverage + run: npm run test:types - name: Run tests - run: npm run test --coverage + # maxWorkers is a workaround for a memory leak that crashes tests in CI: + # https://github.com/facebook/jest/issues/9430#issuecomment-1149882002 + run: npm test -- --coverage --maxWorkers=3 - name: Report test results uses: dorny/test-reporter@eaa763f6ffc21c7a37837f56cd5f9737f27fc6c8 # v1.8.0 diff --git a/apps/browser/package.json b/apps/browser/package.json index 25912c4832f6..a295a0f5bfed 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2024.5.1", + "version": "2024.6.0", "scripts": { "build": "cross-env MANIFEST_VERSION=3 webpack", "build:mv2": "webpack", diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index c4a07015412b..c42516264013 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "إلغاء القفل" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "إظهار خيارات قائمة السياق" }, @@ -799,12 +802,39 @@ "message": "داكن مُشمس", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "تصدير الخزنة" }, "fileFormat": { "message": "صيغة الملف" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "تحذير", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "خطأ" }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 8b3632da1ba3..129aac00ee2b 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Kilidi aç" }, + "additionalOptions": { + "message": "Əlavə seçimlər" + }, "enableContextMenuItem": { "message": "Konteks menyu seçimlərini göstər" }, @@ -799,12 +802,39 @@ "message": "Günəşli tünd", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Buradan xaricə köçür" + }, "exportVault": { "message": "Anbarı xaricə köçür" }, "fileFormat": { "message": "Fayl formatı" }, + "fileEncryptedExportWarningDesc": { + "message": "Bu faylın xaricə köçürülməsi, parolla qorunacaq və şifrəsini açmaq üçün fayl parolu tələb olunacaq." + }, + "filePassword": { + "message": "Fayl parolu" + }, + "exportPasswordDescription": { + "message": "Bu parol, bu faylı daxilə və xaricə köçürmək üçün istifadə olunacaq" + }, + "accountRestrictedOptionDescription": { + "message": "Xaricə köçürməni şifrələmək və daxilə köçürməni yalnız mövcud Bitwarden hesabı ilə məhdudlaşdırmaq üçün hesabınızın istifadəçi adı və Ana Parolundan əldə edilən hesab şifrələmə açarınızı istifadə edin." + }, + "passwordProtectedOptionDescription": { + "message": "Xaricə köçürməni şifrələmək üçün bir fayl parolu təyin edin və şifrəni açma parolunu istifadə edərək bunu istənilən Bitwarden hesabına köçürün." + }, + "exportTypeHeading": { + "message": "Xaricə köçürmə növü" + }, + "accountRestricted": { + "message": "Hesab məhdudlaşdırıldı" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Fayl parolu\" və \"Fayl parolunu təsdiqlə\" uyuşmur." + }, "warning": { "message": "XƏBƏRDARLIQ", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Təşkilat anbarını xaricə köçürmə" + }, + "exportingOrganizationVaultDesc": { + "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat anbarı ixrac ediləcək. Fərdi anbardakı və digər təşkilat elementlər daxil edilmir.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Xəta" }, diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 5409816accf5..6ee3fffdfa5c 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Разблакіраваць" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Паказваць параметры кантэкстнага меню" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Экспартаваць сховішча" }, "fileFormat": { "message": "Фармат файла" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "ПАПЯРЭДЖАННЕ", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Памылка" }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index e6f6cfc14d8e..337467ddfe92 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Отключване" }, + "additionalOptions": { + "message": "Допълнителни настройки" + }, "enableContextMenuItem": { "message": "Показване на опции в контекстното меню" }, @@ -799,12 +802,39 @@ "message": "Преекспонирано тъмен", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Изнасяне от" + }, "exportVault": { "message": "Изнасяне на трезора" }, "fileFormat": { "message": "Формат на файла" }, + "fileEncryptedExportWarningDesc": { + "message": "Изнесеният файл ще бъде защитен с парола, която ще бъде необходима за дешифриране на файла." + }, + "filePassword": { + "message": "Парола на файла" + }, + "exportPasswordDescription": { + "message": "Парола ще се използва при изнасянето и при внасянето на този файл" + }, + "accountRestrictedOptionDescription": { + "message": "Използвайте ключа си за шифриране, който се получава чрез комбиниране на потребителското име на регистрацията Ви и главната парола. С него изнасянето ще се шифрира и внасянето ще бъда възможно само в текущата регистрация в Битуорден." + }, + "passwordProtectedOptionDescription": { + "message": "Задайте парола за файла, за да шифровате изнесените данни. Ще можете да внесете данните във всяка регистрация в Битуорден използвайки паролата за дешифриране." + }, + "exportTypeHeading": { + "message": "Вид изнасяне" + }, + "accountRestricted": { + "message": "Регистрацията е ограничена" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "Дънните в полетата „Парола на файла“ и „Потвърждаване на паролата на файла“ не съвпадат." + }, "warning": { "message": "ВНИМАНИЕ", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Изнасяне на трезора на организацията" + }, + "exportingOrganizationVaultDesc": { + "message": "Ще бъдат изнесени само записите от трезора свързан с $ORGANIZATION$. Записите в отделните лични трезори и тези в други организации няма да бъдат включени.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Грешка" }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 1635de5bb8b7..541bebd427be 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "ভল্ট রফতানি" }, "fileFormat": { "message": "ফাইলের ধরণ" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "সতর্কতা", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index db55f4784e4a..cd82d91cefcd 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 01f66bffd641..fe2a25e65950 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Desbloqueja" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Mostra les opcions del menú contextual" }, @@ -799,12 +802,39 @@ "message": "Solaritzat fosc", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Exporta caixa forta" }, "fileFormat": { "message": "Format de fitxer" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "ADVERTIMENT", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 2939bba14684..3b9507fdf215 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Odemknout" }, + "additionalOptions": { + "message": "Další volby" + }, "enableContextMenuItem": { "message": "Zobrazit volby v kontextovém menu" }, @@ -799,12 +802,39 @@ "message": "Tmavý (solarizovaný)", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Exportovat z" + }, "exportVault": { "message": "Exportovat trezor" }, "fileFormat": { "message": "Formát souboru" }, + "fileEncryptedExportWarningDesc": { + "message": "Tento soubor exportu bude chráněn heslem a k dešifrování bude vyžadovat heslo souboru." + }, + "filePassword": { + "message": "Heslo souboru" + }, + "exportPasswordDescription": { + "message": "Toto heslo bude použito pro export a import tohoto souboru" + }, + "accountRestrictedOptionDescription": { + "message": "Pro zašifrování exportu a omezení importu pouze na aktuální účet Bitwardenu použijte šifrovací klíč Vašeho účtu odvozený z uživatelského jména a hlavního hesla." + }, + "passwordProtectedOptionDescription": { + "message": "Nastavte heslo pro šifrování exportu a importujte ho do libovolného účtu Bitwardenu pomocí hesla pro dešifrování." + }, + "exportTypeHeading": { + "message": "Typ exportu" + }, + "accountRestricted": { + "message": "Účet je omezený" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Heslo souboru\" a \"Potvrzení hesla souboru\" se neshodují." + }, "warning": { "message": "VAROVÁNÍ", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exportování trezoru organizace" + }, + "exportingOrganizationVaultDesc": { + "message": "Exportován bude jen trezor organizace přidružený k položce $ORGANIZATION$. Osobní položky trezoru a položky z jiných organizací nebudou zahrnuty.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Chyba" }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 58f7bc954468..6bfa7de69f3b 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Datgloi" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Allforio'r gell" }, "fileFormat": { "message": "Fformat y ffeil" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "RHYBUDD", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Gwall" }, diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 8f27f95be024..32266ceb31fa 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Oplås" }, + "additionalOptions": { + "message": "Yderligere indstillinger" + }, "enableContextMenuItem": { "message": "Vis indstillinger i kontekstmenuen" }, @@ -799,12 +802,39 @@ "message": "Solariseret mørk", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Eksportér fra" + }, "exportVault": { "message": "Eksportér boks" }, "fileFormat": { "message": "Filformat" }, + "fileEncryptedExportWarningDesc": { + "message": "Denne fileksport vil være adgangskodebeskyttet og kræve filadgangskoden at dekryptere." + }, + "filePassword": { + "message": "Filadgangskode" + }, + "exportPasswordDescription": { + "message": "Denne adgangskode vil blive brugt ved eksport og import af denne fil" + }, + "accountRestrictedOptionDescription": { + "message": "Brug kontokrypteringsnøglen, dannet af kontobrugernavn og Hovedadgangskode, for at kryptere eksporten og hindre import til andre end den aktuelle Bitwarden-konto." + }, + "passwordProtectedOptionDescription": { + "message": "Opsæt en adgangskode til både at kryptere eksporten samt dekryptere denne ved import til enhver Bitwarden-konto." + }, + "exportTypeHeading": { + "message": "Eksporttype" + }, + "accountRestricted": { + "message": "Konto begrænset" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“Filadgangskode” og “Bekræft filadgangskode“ matcher ikke." + }, "warning": { "message": "ADVARSEL", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Eksport af organisationsboks" + }, + "exportingOrganizationVaultDesc": { + "message": "Kun organisationsboksen tilknyttet $ORGANIZATION$ eksporteres. Emner i individuelle bokse eller andre organisationer medtages ikke.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Fejl" }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 8a7c3e0067dc..bec6702ae280 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Entsperren" }, + "additionalOptions": { + "message": "Weitere Optionen" + }, "enableContextMenuItem": { "message": "Kontextmenüoptionen anzeigen" }, @@ -799,12 +802,39 @@ "message": "Solarized Dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export aus" + }, "exportVault": { "message": "Tresor exportieren" }, "fileFormat": { "message": "Dateiformat" }, + "fileEncryptedExportWarningDesc": { + "message": "Dieser Datei-Export ist passwortgeschützt und erfordert das Dateipasswort zum Entschlüsseln." + }, + "filePassword": { + "message": "Dateipasswort" + }, + "exportPasswordDescription": { + "message": "Dieses Passwort wird zum Exportieren und Importieren dieser Datei verwendet" + }, + "accountRestrictedOptionDescription": { + "message": "Verwende den Verschlüsselungsschlüssel deines Kontos, abgeleitet vom Benutzernamen und Master-Passwort, um den Export zu verschlüsseln und den Import auf das aktuelle Bitwarden-Konto zu beschränken." + }, + "passwordProtectedOptionDescription": { + "message": "Lege ein Dateipasswort fest, um den Export zu verschlüsseln und importiere ihn in ein beliebiges Bitwarden-Konto, wobei das Passwort zum Entschlüsseln genutzt wird." + }, + "exportTypeHeading": { + "message": "Exporttyp" + }, + "accountRestricted": { + "message": "Konto eingeschränkt" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "„Dateipasswort“ und „Dateipasswort bestätigen“ stimmen nicht überein." + }, "warning": { "message": "ACHTUNG", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Tresor der Organisation wird exportiert" + }, + "exportingOrganizationVaultDesc": { + "message": "Nur der mit $ORGANIZATION$ verbundene Organisationstresor wird exportiert. Einträge in persönlichen Tresoren oder anderen Organisationen werden nicht berücksichtigt.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Fehler" }, diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 365f8475a07b..da376f6c6a0d 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Ξεκλείδωμα" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Εμφάνιση επιλογών μενού περιβάλλοντος" }, @@ -799,12 +802,39 @@ "message": "Solarized Σκούρο", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Εξαγωγή Vault" }, "fileFormat": { "message": "Μορφή αρχείου" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "ΠΡΟΕΙΔΟΠΟΙΗΣΗ", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Σφάλμα" }, diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 8d0f6fe31eb4..231637d7af33 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -185,7 +185,7 @@ "message": "Continue to browser extension store?" }, "continueToBrowserExtensionStoreDesc": { - "message": "Help others find out if Bitwarden is right for them. Visit your browser's extension store and leave a rating now." + "message": "Help others find out if Bitwarden is right for them. Visit your browser's extension store and leave a rating now." }, "changeMasterPasswordOnWebConfirmation": { "message": "You can change your master password on the Bitwarden web app." @@ -224,7 +224,7 @@ }, "continueToAuthenticatorPageDesc": { "message": "Bitwarden Authenticator allows you to store authenticator keys and generate TOTP codes for 2-step verification flows. Learn more on the bitwarden.com website" - }, + }, "bitwardenSecretsManager": { "message": "Bitwarden Secrets Manager" }, @@ -389,6 +389,9 @@ "favorite": { "message": "Favorite" }, + "unfavorite": { + "message": "Unfavorite" + }, "notes": { "message": "Notes" }, @@ -410,6 +413,9 @@ "launch": { "message": "Launch" }, + "launchWebsite": { + "message": "Launch website" + }, "website": { "message": "Website" }, @@ -599,6 +605,9 @@ "loggedOut": { "message": "Logged out" }, + "loggedOutDesc": { + "message": "You have been logged out of your account." + }, "loginExpired": { "message": "Your login session has expired." }, @@ -762,6 +771,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -816,7 +828,7 @@ }, "exportPasswordDescription": { "message": "This password will be used to export and import this file" - }, + }, "accountRestrictedOptionDescription": { "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." }, @@ -1104,6 +1116,15 @@ "selfHostedEnvironmentFooter": { "message": "Specify the base URL of your on-premises hosted Bitwarden installation." }, + "selfHostedBaseUrlHint": { + "message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com" + }, + "selfHostedCustomEnvHeader" :{ + "message": "For advanced configuration, you can specify the base URL of each service independently." + }, + "selfHostedEnvFormInvalid" :{ + "message": "You must add either the base Server URL or at least one custom environment." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1416,6 +1437,15 @@ "collections": { "message": "Collections" }, + "nCollections": { + "message": "$COUNT$ collections", + "placeholders": { + "count": { + "content": "$1", + "example": "2" + } + } + }, "favorites": { "message": "Favorites" }, @@ -1645,6 +1675,9 @@ "autoFillAndSave": { "message": "Auto-fill and save" }, + "fillAndSave": { + "message": "Fill and save" + }, "autoFillSuccessAndSavedUri": { "message": "Item auto-filled and URI saved" }, @@ -1741,6 +1774,12 @@ "ok": { "message": "Ok" }, + "errorRefreshingAccessToken":{ + "message": "Access Token Refresh Error" + }, + "errorRefreshingAccessTokenDesc":{ + "message": "No refresh token or API keys found. Please try logging out and logging back in." + }, "desktopSyncVerificationTitle": { "message": "Desktop sync verification" }, @@ -3260,7 +3299,7 @@ "clearFiltersOrTryAnother": { "message": "Clear filters or try another search term" }, - "copyInfo": { + "copyInfoLabel": { "message": "Copy info, $ITEMNAME$", "description": "Aria label for a button that opens a menu with options to copy information from an item.", "placeholders": { @@ -3270,7 +3309,37 @@ } } }, - "moreOptions": { + "copyInfoTitle": { + "message": "Copy info - $ITEMNAME$", + "description": "Title for a button that opens a menu with options to copy information from an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "copyNoteLabel": { + "message": "Copy Note, $ITEMNAME$", + "description": "Aria label for a button copies a note to the clipboard.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Note Item" + } + } + }, + "copyNoteTitle": { + "message": "Copy Note - $ITEMNAME$", + "description": "Title for a button copies a note to the clipboard.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Note Item" + } + } + }, + "moreOptionsLabel": { "message": "More options, $ITEMNAME$", "description": "Aria label for a button that opens a menu with more options for an item.", "placeholders": { @@ -3280,6 +3349,38 @@ } } }, + "moreOptionsTitle": { + "message": "More options - $ITEMNAME$", + "description": "Title for a button that opens a menu with more options for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "viewItemTitle": { + "message": "View item - $ITEMNAME$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + } + } + }, + "assignCollections": { + "message": "Assign collections" + }, + "copyEmail": { + "message": "Copy email" + }, + "copyPhone": { + "message": "Copy phone" + }, + "copyAddress": { + "message": "Copy address" + }, "adminConsole": { "message": "Admin Console" }, @@ -3330,5 +3431,14 @@ "example": "Work" } } + }, + "itemsWithNoFolder": { + "message": "Items with no folder" + }, + "organizationIsDeactivated": { + "message": "Organization is deactivated" + }, + "contactYourOrgAdmin": { + "message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance." } } diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index ffde447604bc..0ff4085900c0 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organisation vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organisations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 3ceedc40deb1..bb1ae5f73184 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarised Dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organisation vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organisations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 3550fa68c03a..1134f9415130 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Desbloquear" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Mostrar las opciones de menú contextuales" }, @@ -799,12 +802,39 @@ "message": "Solarized Dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Exportar desde" + }, "exportVault": { "message": "Exportar caja fuerte" }, "fileFormat": { "message": "Formato de archivo" }, + "fileEncryptedExportWarningDesc": { + "message": "Esta exportación de archivo estará protegida por contraseña y requerirá la contraseña del archivo para descifrarla." + }, + "filePassword": { + "message": "Contraseña del archivo" + }, + "exportPasswordDescription": { + "message": "Esta contraseña se utilizará para exportar e importar este archivo" + }, + "accountRestrictedOptionDescription": { + "message": "Utiliza la clave de cifrado de tu cuenta, derivada del nombre de usuario y la contraseña maestra de tu cuenta, para cifrar la exportación y restringir la importación solo a la cuenta actual de Bitwarden." + }, + "passwordProtectedOptionDescription": { + "message": "Establece una contraseña de archivo para cifrar la exportación e importarlo a cualquier cuenta de Bitwarden utilizando la contraseña para el descifrado." + }, + "exportTypeHeading": { + "message": "Tipo de exportación" + }, + "accountRestricted": { + "message": "Cuenta restringida" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Contraseña de archivo\" y \"Confirmar contraseña de archivo\" no coinciden." + }, "warning": { "message": "ADVERTENCIA", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exportando caja fuerte de la organización" + }, + "exportingOrganizationVaultDesc": { + "message": "Solo se exportará la caja fuerte de la organización asociada a $ORGANIZATION$. Los elementos en las cajas fuertes individuales o de otras organizaciones no serán incluidos.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 8fb5274c52fe..03267c79d7b7 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Lukusta lahti" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Kuva parema kliki menüü valikud" }, @@ -799,12 +802,39 @@ "message": "Solarized tume", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Ekspordi hoidla" }, "fileFormat": { "message": "Failivorming" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "HOIATUS", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Viga" }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index 60339f16b14f..3d0e37f4147d 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Erakutsi laster-menuko aukerak" }, @@ -799,12 +802,39 @@ "message": "Solarized iluna", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Esportatu kutxa gotorra" }, "fileFormat": { "message": "Fitxategiaren formatua" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "KONTUZ", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Akatsa" }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index a493a1c24bfd..5b55561a6a8e 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "باز کردن قفل" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "نمایش گزینه‌های منوی زمینه" }, @@ -799,12 +802,39 @@ "message": "تاریک خورشیدی", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "برون ریزی گاوصندوق" }, "fileFormat": { "message": "فرمت پرونده" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "اخطار", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "خطا" }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 05d2d3ed952f..42251e9e42b2 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Avaa" }, + "additionalOptions": { + "message": "Lisäasetukset" + }, "enableContextMenuItem": { "message": "Näytä sisältövalikon valinnat" }, @@ -799,12 +802,39 @@ "message": "Solarized, tumma", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Vie lähteestä" + }, "exportVault": { "message": "Vie holvi" }, "fileFormat": { "message": "Tiedostomuoto" }, + "fileEncryptedExportWarningDesc": { + "message": "Tämä vientitiedosto suojataan salasanalla, joka on syötettävä ja salauksen purkamiseksi." + }, + "filePassword": { + "message": "Tiedoston salasana" + }, + "exportPasswordDescription": { + "message": "Tätä salasanaa käytetään tämän tiedoston viennissä ja tuonnissa" + }, + "accountRestrictedOptionDescription": { + "message": "Salaa vienti ja rajoita tuonti vain nykyiselle Bitwarden-tilille tilisi käyttäjätunnukseen ja pääsalasanaan pohjautuvalla salausavaimella." + }, + "passwordProtectedOptionDescription": { + "message": "Salaa vientitiedosto salasanalla, joka mahdollistaa sen tuonnin mille tahansa Bitwarden-tilille." + }, + "exportTypeHeading": { + "message": "Viennin tyyppi" + }, + "accountRestricted": { + "message": "Rajoitettu tilille" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Tiedoston salasana\" ja \"Vahvista tiedoston salasana\" eivät täsmää." + }, "warning": { "message": "VAROITUS", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Organisaation holvin vienti" + }, + "exportingOrganizationVaultDesc": { + "message": "Vain organisaatioon $ORGANIZATION$ liitetyn holvin kohteet viedään. Yksityisen holvin ja muiden organisaatioiden kohteita ei sisällytetä.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Virhe" }, diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 061da97446b8..86f8209e1cd6 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "I-unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Ipakita ang mga opsyon ng menu ng konteksto" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "I-export vault" }, "fileFormat": { "message": "Format ng file" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "BABALA", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Mali" }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index c062390f228a..53df48d00041 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Déverrouiller" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Afficher les options du menu contextuel" }, @@ -799,12 +802,39 @@ "message": "Solarized Dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Exporter le coffre" }, "fileFormat": { "message": "Format de fichier" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "AVERTISSEMENT", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Erreur" }, diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index b92e05269ebb..dd29c8a071cd 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Desbloquear" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarizado escuro", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Exportar caixa forte" }, "fileFormat": { "message": "Formato de ficheiro" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "ADVERTENCIA", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Erro" }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index fd49f7e55e8c..c191df36bf86 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "יצוא כספת" }, "fileFormat": { "message": "פורמט קובץ" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "אזהרה", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "שגיאה" }, diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 48c55b30a83f..d6ac94bec327 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "संदर्भ मेनू विकल्प दिखाएं" }, @@ -799,12 +802,39 @@ "message": "सौरीकृत अंधेरा", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export Vault" }, "fileFormat": { "message": "File Format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "चेतावनी", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "एरर" }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index ac452af85ee7..8b9f96fa5c4a 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Otključaj" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Prikaži opcije kotekstualnog izbornika" }, @@ -799,12 +802,39 @@ "message": "Solarized Dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Izvezi trezor" }, "fileFormat": { "message": "Format datoteke" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "UPOZORENJE", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Pogreška" }, diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 8b937375a638..8ff50ea4f085 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Feloldás" }, + "additionalOptions": { + "message": "Kiegészítő opciók" + }, "enableContextMenuItem": { "message": "Helyi menü opciók megjelenítése" }, @@ -799,12 +802,39 @@ "message": "Szolarizált sötét", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Exportálás innen:" + }, "exportVault": { "message": "Széf exportálása" }, "fileFormat": { "message": "Fájlformátum" }, + "fileEncryptedExportWarningDesc": { + "message": "Ez a fájl exportálás jelszóval védett és a visszafejtéshez a fájl jelszó megadása szükséges." + }, + "filePassword": { + "message": "Fájl jelszó" + }, + "exportPasswordDescription": { + "message": "Ezt a jelszó kerül használatba a fájl exportálására és importálására." + }, + "accountRestrictedOptionDescription": { + "message": "Használjuk a fiók felhasználónevéből és mesterjelszavából származó fióktitkosítási kulcsot az exportálás titkosításához és az importálást csak az aktuális Bitwarden fiókra korlátozzuk." + }, + "passwordProtectedOptionDescription": { + "message": "Állítsunk be egy fájl jelszót az exportálás titkosításához és importáljuk azt bármely Bitwarden fiókba a visszafejtéshez használt jelszó használatával." + }, + "exportTypeHeading": { + "message": "Exportálási típus" + }, + "accountRestricted": { + "message": "Korlátozott fiók" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "A “Fájl jelszó” és a “Fájl jelszó megerősítés“ nem egyezik." + }, "warning": { "message": "FIGYELEM", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Szervezeti széf exportálása" + }, + "exportingOrganizationVaultDesc": { + "message": "Csak $ORGANIZATION$ névvel társított szervezeti széf kerül exportálásra. Ebbe nem kerülnek be a személyes és más szervezeti széf elemek.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Hiba" }, diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index b151dbbf21fc..19770da2782a 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Gelap Solarized", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Ekspor Brankas" }, "fileFormat": { "message": "Format Berkas" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "PERINGATAN", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Galat" }, diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 938eb7671e09..614068b1f4d5 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Sblocca" }, + "additionalOptions": { + "message": "Opzioni aggiuntive" + }, "enableContextMenuItem": { "message": "Mostra opzioni nel menu contestuale" }, @@ -799,12 +802,39 @@ "message": "Scuro solarizzato", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Esporta da" + }, "exportVault": { "message": "Esporta cassaforte" }, "fileFormat": { "message": "Formato file" }, + "fileEncryptedExportWarningDesc": { + "message": "Questo file esportato sarà protetto e richiederà la password del file per decifrarlo." + }, + "filePassword": { + "message": "Password del file" + }, + "exportPasswordDescription": { + "message": "La password sarà utilizzata per importare ed esportare questo file" + }, + "accountRestrictedOptionDescription": { + "message": "Usa la chiave di crittografia dell'account, derivata dal nome utente e dalla password principale del tuo account, per crittografare il file di esportazione e limitare l'importazione solo all'account Bitwarden corrente." + }, + "passwordProtectedOptionDescription": { + "message": "Imposta una password del file per crittografare il file esportato e importarlo in qualsiasi account Bitwarden usando la password per decrittografarlo." + }, + "exportTypeHeading": { + "message": "Tipo di esportazione" + }, + "accountRestricted": { + "message": "Account limitato" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "Le due password del file non corrispondono." + }, "warning": { "message": "ATTENZIONE", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Esportando cassaforte dell'organizzazione" + }, + "exportingOrganizationVaultDesc": { + "message": "Solo la cassaforte dell'organizzazione associata a $ORGANIZATION$ sarà esportata. Elementi nelle casseforti individuali o in altre organizzazioni non saranno inclusi.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Errore" }, diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index cb78bd7fda5e..9113b95a7d3d 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "ロック解除" }, + "additionalOptions": { + "message": "追加オプション" + }, "enableContextMenuItem": { "message": "コンテキストメニューオプションを表示" }, @@ -799,12 +802,39 @@ "message": "Solarized ダーク", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "エクスポート元" + }, "exportVault": { "message": "保管庫のエクスポート" }, "fileFormat": { "message": "ファイル形式" }, + "fileEncryptedExportWarningDesc": { + "message": "エクスポートするファイルはパスワードで保護され、復号するにはファイルパスワードが必要になります。" + }, + "filePassword": { + "message": "ファイルパスワード" + }, + "exportPasswordDescription": { + "message": "このパスワードはこのファイルのエクスポートとインポート時に使用します" + }, + "accountRestrictedOptionDescription": { + "message": "アカウントのユーザー名とマスターパスワードから得られる暗号化キーを使用してエクスポートするデータを暗号化し、現在の Bitwarden アカウントのみがインポートできるよう制限します。" + }, + "passwordProtectedOptionDescription": { + "message": "エクスポートを暗号化するためのファイルパスワードを設定します。そのパスワードを使用して、任意の Bitwarden アカウントにインポートします。" + }, + "exportTypeHeading": { + "message": "エクスポートの種類" + }, + "accountRestricted": { + "message": "アカウント制限" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "「ファイルパスワード」と「ファイルパスワードの確認」が一致しません。" + }, "warning": { "message": "警告", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "組織保管庫のエクスポート" + }, + "exportingOrganizationVaultDesc": { + "message": "$ORGANIZATION$ に関連付けられた組織保管庫のみがエクスポートされます。個々の保管庫または他の組織にあるアイテムは含まれません。", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "エラー" }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 62347f4acf45..6abd24dafc86 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 64f3623b5af3..c3f03166b255 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 7556ee6afd60..e5e1f708f66d 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "ಡಾರ್ಕ್ ಸೌರ", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "ರಫ್ತು ವಾಲ್ಟ್" }, "fileFormat": { "message": "ಕಡತದ ಮಾದರಿ" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "ಎಚ್ಚರಿಕೆ", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 86295d469a75..552446ef7669 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "잠금 해제" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized Dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "보관함 내보내기" }, "fileFormat": { "message": "파일 형식" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "경고", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "오류" }, diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 6f0fc1bbb62e..5b426e47d159 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -423,7 +423,7 @@ "message": "Kita" }, "unlockMethods": { - "message": "Unlock options" + "message": "Atrakinti parinktis" }, "unlockMethodNeededToChangeTimeoutActionDesc": { "message": "Nustatyk atrakinimo būdą, kad pakeistum saugyklos laiko limito veiksmą." @@ -432,10 +432,10 @@ "message": "Nustatykite nustatymuose atrakinimo metodą" }, "sessionTimeoutHeader": { - "message": "Session timeout" + "message": "Baigėsi seanso laikas" }, "otherOptions": { - "message": "Other options" + "message": "Kitos parinktys" }, "rateExtension": { "message": "Įvertinkite šį plėtinį" @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Atrakinti" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Rodyti kontekstinio meniu pasririnkimus" }, @@ -799,12 +802,39 @@ "message": "Saulėtas tamsą", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Eksportuoti saugyklą" }, "fileFormat": { "message": "Failo formatas" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "ĮSPĖJIMAS", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Klaida" }, @@ -2232,7 +2274,7 @@ "message": "Sugeneruoti el. pašto slapyvardį su išorine persiuntimo paslauga." }, "forwarderError": { - "message": "$SERVICENAME$ error: $ERRORMESSAGE$", + "message": "„$SERVICENAME$“ klaida: $ERRORMESSAGE$.", "description": "Reports an error returned by a forwarding service to the user.", "placeholders": { "servicename": { @@ -2246,11 +2288,11 @@ } }, "forwarderGeneratedBy": { - "message": "Generated by Bitwarden.", + "message": "Sugeneravo „Bitwarden“.", "description": "Displayed with the address on the forwarding service's configuration screen." }, "forwarderGeneratedByWithWebsite": { - "message": "Website: $WEBSITE$. Generated by Bitwarden.", + "message": "Svetainė: $WEBSITE$. Sugeneravo „Bitwarden“.", "description": "Displayed with the address on the forwarding service's configuration screen.", "placeholders": { "WEBSITE": { @@ -2260,7 +2302,7 @@ } }, "forwaderInvalidToken": { - "message": "Invalid $SERVICENAME$ API token", + "message": "Netinkamas „$SERVICENAME$“ API prieigos raktas.", "description": "Displayed when the user's API token is empty or rejected by the forwarding service.", "placeholders": { "servicename": { @@ -2270,7 +2312,7 @@ } }, "forwaderInvalidTokenWithMessage": { - "message": "Invalid $SERVICENAME$ API token: $ERRORMESSAGE$", + "message": "Netinkamas „$SERVICENAME$“ API prieigos raktas: $ERRORMESSAGE$.", "description": "Displayed when the user's API token is rejected by the forwarding service with an error message.", "placeholders": { "servicename": { @@ -2284,7 +2326,7 @@ } }, "forwarderNoAccountId": { - "message": "Unable to obtain $SERVICENAME$ masked email account ID.", + "message": "Nepavyksta gauti „$SERVICENAME$“ užmaskuoto el. pašto paskyros ID.", "description": "Displayed when the forwarding service fails to return an account ID.", "placeholders": { "servicename": { @@ -2294,7 +2336,7 @@ } }, "forwarderNoDomain": { - "message": "Invalid $SERVICENAME$ domain.", + "message": "Netinkamas „$SERVICENAME$“ domenas.", "description": "Displayed when the domain is empty or domain authorization failed at the forwarding service.", "placeholders": { "servicename": { @@ -2304,7 +2346,7 @@ } }, "forwarderNoUrl": { - "message": "Invalid $SERVICENAME$ url.", + "message": "Netinkamas „$SERVICENAME$“ URL.", "description": "Displayed when the url of the forwarding service wasn't supplied.", "placeholders": { "servicename": { @@ -2314,7 +2356,7 @@ } }, "forwarderUnknownError": { - "message": "Unknown $SERVICENAME$ error occurred.", + "message": "Įvyko nežinoma „$SERVICENAME$“ klaida.", "description": "Displayed when the forwarding service failed due to an unknown error.", "placeholders": { "servicename": { @@ -2324,7 +2366,7 @@ } }, "forwarderUnknownForwarder": { - "message": "Unknown forwarder: '$SERVICENAME$'.", + "message": "Nežinomas persiuntėjas: „$SERVICENAME$“.", "description": "Displayed when the forwarding service is not supported.", "placeholders": { "servicename": { @@ -3245,13 +3287,13 @@ "message": "Administratoriaus konsolės" }, "accountSecurity": { - "message": "Account security" + "message": "Paskyros saugumas" }, "notifications": { - "message": "Notifications" + "message": "Pranešimai" }, "appearance": { - "message": "Appearance" + "message": "Išvaizda" }, "errorAssigningTargetCollection": { "message": "Klaida priskiriant tikslinę kolekciją." diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index a7b50ae2a473..ac202682bab0 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Atslēgt" }, + "additionalOptions": { + "message": "Papildu iespējas" + }, "enableContextMenuItem": { "message": "Rādīt konteksta izvēlnes iespējas" }, @@ -799,12 +802,39 @@ "message": "Solarized Dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Izgūt no" + }, "exportVault": { "message": "Izgūt glabātavas saturu" }, "fileFormat": { "message": "Datnes veids" }, + "fileEncryptedExportWarningDesc": { + "message": "Šī datņu izgūšana būs aizsargāta ar paroli, un būs nepieciešama datnes parole, lai to atšifrētu." + }, + "filePassword": { + "message": "Datnes parole" + }, + "exportPasswordDescription": { + "message": "Šī parole tiks izmantota, lai izgūtu un ievietotu šo datni" + }, + "accountRestrictedOptionDescription": { + "message": "Jāizmanto konta šifrēšanas atslēga, kas iegūta no lietotājvārda un galvenās paroles, lai šifrētu izguvi un atļautu ievietošanu tikai pašreizējā Bitwarden kontā." + }, + "passwordProtectedOptionDescription": { + "message": "Uzstādīt paroli, lai šifrētu izguvi un tad to ievietotu jebkurā Bitwarden kontā, izmantojot atšifrēšanas paroli." + }, + "exportTypeHeading": { + "message": "Izgūšanas veids" + }, + "accountRestricted": { + "message": "Konts ir ierobežots" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Datnes parole\" un \"Apstiprināt datnes paroli\" vērtības nesakrīt." + }, "warning": { "message": "UZMANĪBU", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -813,7 +843,7 @@ "message": "Apstiprināt glabātavas satura izgūšanu" }, "exportWarningDesc": { - "message": "Šī izguve satur glabātavas datus nešifrētā veidā. Izdoto datni nevajadzētu glabāt vai sūtīt nedrošos veidos (piemēram, e-pastā). Izdzēst to uzreiz pēc izmantošanas." + "message": "Šī izguve satur glabātavas datus nešifrētā veidā. Izgūto datni nevajadzētu glabāt vai sūtīt nedrošos veidos (piemēram, e-pastā). Tā ir jāizdzēš uzreiz pēc izmantošanas." }, "encExportKeyWarningDesc": { "message": "Šī izguve šifrē datus ar konta šifrēšanas atslēgu. Ja tā jebkad tiks mainīta, izvadi vajadzētu veikt vēlreiz, jo vairs nebūs iespējams atšifrēt šo datni." @@ -2171,7 +2201,7 @@ "message": "Sesijai iestājās noildze. Lūgums mēģināt pieteikties vēlreiz." }, "exportingPersonalVaultTitle": { - "message": "Izdod personīgo glabātavu" + "message": "Izgūst personīgo glabātavu" }, "exportingIndividualVaultDescription": { "message": "Tiks izgūti tikai atsevišķi glabātavas vienumi, kas ir saistīti ar $EMAIL$. Apvienības glabātavas vienumi netiks iekļauti. Tiks izgūta tikai glabātavas vienumu informācija, un saistītie pielikumi netiks iekļauti.", @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Izgūst apvienības glabātavu" + }, + "exportingOrganizationVaultDesc": { + "message": "Tiks izgūta tikai apvienības glabātava, kas ir saistīta ar $ORGANIZATION$. Atsevišķu glabātavu vai citu apvienību vienumi netiks iekļauti.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Kļūda" }, @@ -2907,7 +2949,7 @@ "message": "Kļūda izguves datnes atšifrēšanā. Izmantotā atslēga neatbilst tai, kas tika izmantota satura izgūšanai." }, "invalidFilePassword": { - "message": "Nederīga datnes parole, lūgums izmantot to paroli, kas tika ievadīta izdošanas datnes izveidošanas brīdī." + "message": "Nederīga datnes parole, lūgums izmantot to paroli, kas tika ievadīta izgūšanas datnes izveidošanas brīdī." }, "importDestination": { "message": "Ievietošanas galamērķis" diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index fba3e5486a50..d2a9b2db3898 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "വാൾട് എക്സ്പോർട്" }, "fileFormat": { "message": "ഫയൽ ഫോർമാറ്റ്" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "മുന്നറിയിപ്പ്", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index fe984cb83026..6407be2c68db 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 64f3623b5af3..c3f03166b255 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 6993e1f648c2..d2feb8798e52 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Lås opp" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Vis alternativer for kontekstmeny" }, @@ -799,12 +802,39 @@ "message": "Solarisert mørk", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Eksporter hvelvet" }, "fileFormat": { "message": "Filformat" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "ADVARSEL", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Feil" }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 64f3623b5af3..c3f03166b255 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 36a8ad9470ae..07c69ec596cf 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Ontgrendelen" }, + "additionalOptions": { + "message": "Extra instellingen" + }, "enableContextMenuItem": { "message": "Contextmenu-opties weergeven" }, @@ -799,12 +802,39 @@ "message": "Overbelicht donker", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Exporteren vanuit" + }, "exportVault": { "message": "Kluis exporteren" }, "fileFormat": { "message": "Bestandsindeling" }, + "fileEncryptedExportWarningDesc": { + "message": "We beveiligen deze bestandsexport met een wachtwoord beveiligd, je hebt het bestandswachtwoord nodig om het te decoderen." + }, + "filePassword": { + "message": "Bestandswachtwoord" + }, + "exportPasswordDescription": { + "message": "We gebruiken dit wachtwoord bij het exporteren en importeren van dit bestand" + }, + "accountRestrictedOptionDescription": { + "message": "Gebruik de encryptiesleutel van je account, afgeleid van je gebruikersnaam en hoodfwachtwoord, om de export te versleutelen en importeren te beperken tot het huidige Bitwarden-account." + }, + "passwordProtectedOptionDescription": { + "message": "Stel een bestandswachtwoord in om de export te versleutelen en te importeren naar een willekeurig Bitwarden-account met het wachtwoord voor decoderen." + }, + "exportTypeHeading": { + "message": "Exporttype" + }, + "accountRestricted": { + "message": "Account beperkt" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Bestandswachtwoord\" en \"Bestandswachtwoord bevestigen\" komen niet overeen." + }, "warning": { "message": "WAARSCHUWING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Organisatiekluis exporteren" + }, + "exportingOrganizationVaultDesc": { + "message": "Exporteert alleen de organisatiekluis van $ORGANIZATION$. Geen persoonlijke kluis-items of items van andere organisaties.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Fout" }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 64f3623b5af3..c3f03166b255 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 64f3623b5af3..c3f03166b255 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index d5e3940682f8..c9959508c95b 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Odblokuj" }, + "additionalOptions": { + "message": "Dodatkowe opcje" + }, "enableContextMenuItem": { "message": "Pokaż opcje menu kontekstowego" }, @@ -799,12 +802,39 @@ "message": "Solarized Dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Eksportuj z" + }, "exportVault": { "message": "Eksportuj sejf" }, "fileFormat": { "message": "Format pliku" }, + "fileEncryptedExportWarningDesc": { + "message": "Plik będzie chroniony hasłem, które będzie wymagane do odszyfrowania pliku." + }, + "filePassword": { + "message": "Hasło do pliku" + }, + "exportPasswordDescription": { + "message": "Hasło będzie używane do eksportowania i importowania pliku" + }, + "accountRestrictedOptionDescription": { + "message": "Użyj klucza szyfrowania konta, pochodzącego z nazwy użytkownika konta i hasła głównego, aby zaszyfrować eksport i ograniczyć import tylko do bieżącego konta Bitwarden." + }, + "passwordProtectedOptionDescription": { + "message": "Ustaw hasło dla pliku, aby zaszyfrować eksport i zaimportować je na dowolne konto Bitwarden przy użyciu hasła do odszyfrowania." + }, + "exportTypeHeading": { + "message": "Rodzaj eksportu" + }, + "accountRestricted": { + "message": "Konto ograniczone" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“Hasło pliku” i “Potwierdź hasło pliku“ nie pasują do siebie." + }, "warning": { "message": "UWAGA", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Eksportowanie sejfu organizacji" + }, + "exportingOrganizationVaultDesc": { + "message": "Tylko sejf organizacji powiązany z $ORGANIZATION$ zostanie wyeksportowany. Pozycje w poszczególnych sejfach lub innych organizacji nie będą uwzględnione.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Błąd" }, diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 0a65acf9586e..7f247311df6e 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Desbloquear" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Mostrar opções de menu de contexto" }, @@ -799,12 +802,39 @@ "message": "Solarized (escuro)", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Exportar Cofre" }, "fileFormat": { "message": "Formato de arquivo" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "AVISO", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Erro" }, diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 7f4b488fa378..bcbddaff0a22 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Desbloquear" }, + "additionalOptions": { + "message": "Opções adicionais" + }, "enableContextMenuItem": { "message": "Mostrar opções do menu de contexto" }, @@ -799,12 +802,39 @@ "message": "Solarized (escuro)", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Exportar de" + }, "exportVault": { "message": "Exportar cofre" }, "fileFormat": { "message": "Formato do ficheiro" }, + "fileEncryptedExportWarningDesc": { + "message": "A exportação deste ficheiro será protegida por uma palavra-passe e exigirá a palavra-passe do ficheiro para ser desencriptada." + }, + "filePassword": { + "message": "Palavra-passe do ficheiro" + }, + "exportPasswordDescription": { + "message": "Esta palavra-passe será utilizada para exportar e importar este ficheiro" + }, + "accountRestrictedOptionDescription": { + "message": "Utilize a chave de encriptação da sua conta, derivada do nome de utilizador e da palavra-passe mestra da sua conta, para encriptar a exportação e restringir a importação apenas à conta Bitwarden atual." + }, + "passwordProtectedOptionDescription": { + "message": "Defina uma palavra-passe do ficheiro para encriptar a exportação e importe-a para qualquer conta Bitwarden utilizando a palavra-passe de desencriptação." + }, + "exportTypeHeading": { + "message": "Tipo de exportação" + }, + "accountRestricted": { + "message": "Conta restringida" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Palavra-passe do ficheiro\" e \"Confirmar palavra-passe do ficheiro\" não correspondem." + }, "warning": { "message": "AVISO", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "A exportar o cofre da organização" + }, + "exportingOrganizationVaultDesc": { + "message": "Apenas o cofre da organização associado a $ORGANIZATION$ será exportado. Os itens em cofres individuais ou noutras organizações não serão incluídos.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Erro" }, diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 7c5cd8a3acdc..a9dae9496b6b 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Deblocare" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Afișați opțiunile meniului contextual" }, @@ -799,12 +802,39 @@ "message": "Întuneric solarizat", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export seif" }, "fileFormat": { "message": "Format fișier" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "AVERTISMENT", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Eroare" }, diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index cbabda5066a0..438a04e96396 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Разблокировать" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Показать опции контекстного меню" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Экспорт из" + }, "exportVault": { "message": "Экспорт хранилища" }, "fileFormat": { "message": "Формат файла" }, + "fileEncryptedExportWarningDesc": { + "message": "Экспорт этого файла будет защищен паролем, и для расшифровки потребуется пароль файла." + }, + "filePassword": { + "message": "Пароль к файлу" + }, + "exportPasswordDescription": { + "message": "Этот пароль будет использоваться для экспорта и импорта этого файла" + }, + "accountRestrictedOptionDescription": { + "message": "Использовать ключ шифрования вашего аккаунта, полученный из имени пользователя и мастер-пароля, для шифрования экспорта и ограничения импорта только для текущего аккаунта Bitwarden." + }, + "passwordProtectedOptionDescription": { + "message": "Установите пароль файла для шифрования экспорта и импортируйте его в любую учетную запись Bitwarden, используя пароль для расшифровки." + }, + "exportTypeHeading": { + "message": "Тип экспорта" + }, + "accountRestricted": { + "message": "Ограничено аккаунтом" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Пароль к файлу\" и \"Подтверждение пароля к файлу\" не совпадают." + }, "warning": { "message": "ВНИМАНИЕ", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Экспорт хранилища организации" + }, + "exportingOrganizationVaultDesc": { + "message": "Будет экспортировано только хранилище организации, связанное с $ORGANIZATION$. Элементы из личных хранилищ и из других организаций включены не будут.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Ошибка" }, diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index b9078c4c0f82..9fa1bb2339ca 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "අඳුරු අඳුරු", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "අපනයන සුරක්ෂිතාගාරය" }, "fileFormat": { "message": "ගොනු ආකෘතිය" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "අවවාදයයි", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 63bc7a317c0d..b9d7ec78cda5 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Odomknúť" }, + "additionalOptions": { + "message": "Ďalšie možnosti" + }, "enableContextMenuItem": { "message": "Zobraziť možnosti kontextovej ponuky" }, @@ -799,12 +802,39 @@ "message": "Solarized –⁠ tmavý", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Exportovať z" + }, "exportVault": { "message": "Export trezoru" }, "fileFormat": { "message": "Formát Súboru" }, + "fileEncryptedExportWarningDesc": { + "message": "Tento exportovaný súbor bude chránený heslom a na dešifrovanie bude potrebné heslo súboru." + }, + "filePassword": { + "message": "Heslo súboru" + }, + "exportPasswordDescription": { + "message": "Toto heslo sa použije na export a import tohto súboru" + }, + "accountRestrictedOptionDescription": { + "message": "Na zašifrovanie exportu a obmedzenie importu len na aktuálny účet Bitwarden použite šifrovací kľúč účtu odvodený z používateľského mena a hlavného hesla účtu." + }, + "passwordProtectedOptionDescription": { + "message": "Nastavte heslo súboru na zašifrovanie exportu a importujte ho do akéhokoľvek účtu Bitwarden pomocou hesla na dešifrovanie." + }, + "exportTypeHeading": { + "message": "Typ exportu" + }, + "accountRestricted": { + "message": "Obmedzený účet" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Heslo súboru\" a \"Potvrdiť heslo súboru\" sa nezhodujú." + }, "warning": { "message": "UPOZORNENIE", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exportovanie trezora organizácie" + }, + "exportingOrganizationVaultDesc": { + "message": "Exportované budú iba položky trezora organizácie spojené s $ORGANIZATION$. Položky osobného trezora a položky z iných organizácií nebudú zahrnuté.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Chyba" }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index c12a31436fd5..7333c0d91cc4 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Prikaži možnosti kontekstnega menuja" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Izvoz trezorja" }, "fileFormat": { "message": "Format datoteke" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "OPOZORILO", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Napaka" }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 941b38132a4f..3d15f2464991 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Откључај" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Прикажи контекстни мени" }, @@ -799,12 +802,39 @@ "message": "Solarized црно", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Извоз сефа" }, "fileFormat": { "message": "Формат датотеке" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "УПОЗОРЕЊЕ", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Грешка" }, diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 585d8e2d9a03..deb7fda04b33 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Lås upp" }, + "additionalOptions": { + "message": "Ytterligare alternativ" + }, "enableContextMenuItem": { "message": "Visa alternativ för snabbmenyn" }, @@ -799,12 +802,39 @@ "message": "Solarized mörk", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Exportera från" + }, "exportVault": { "message": "Exportera valv" }, "fileFormat": { "message": "Filformat" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "Fillösenord" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Exporttyp" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "VARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Fel" }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 64f3623b5af3..c3f03166b255 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -799,12 +802,39 @@ "message": "Solarized dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index bd708d0d6e4c..f6fc2f4c1de5 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Unlock" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "แสดงตัวเลือกเมนูบริบท" }, @@ -799,12 +802,39 @@ "message": "Solarized Dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export Vault" }, "fileFormat": { "message": "File Format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "คำเตือน", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Error" }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 596e779913a9..4bee651cdbeb 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -185,7 +185,7 @@ "message": "Tarayıcınızın uzantı sitesine gitmek ister misiniz?" }, "continueToBrowserExtensionStoreDesc": { - "message": "Help others find out if Bitwarden is right for them. Visit your browser's extension store and leave a rating now." + "message": "Bitwarden'ı başkalarına da tanımak ister misiniz? Tarayıcınızın uzantı mağazasını ziyaret edip Bitwarden'ı değerlendirin." }, "changeMasterPasswordOnWebConfirmation": { "message": "Ana parolanızı Bitwarden web uygulamasında değiştirebilirsiniz." @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Kilidi aç" }, + "additionalOptions": { + "message": "Ek seçenekler" + }, "enableContextMenuItem": { "message": "Bağlam menüsü seçeneklerini göster" }, @@ -799,12 +802,39 @@ "message": "Solarized koyu", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Kasayı dışa aktar" }, "fileFormat": { "message": "Dosya biçimi" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "Dosya parolası" + }, + "exportPasswordDescription": { + "message": "Bu parola, bu dosyayı dışa ve içe aktarmak için kullanılacaktır" + }, + "accountRestrictedOptionDescription": { + "message": "Dışa aktarmayı şifrelemek ve içe aktarmayı yalnızca mevcut Bitwarden hesabıyla kısıtlamak için, hesabınızın kullanıcı adı ve ana parolasından türetilen hesap şifreleme anahtarınızı kullanın." + }, + "passwordProtectedOptionDescription": { + "message": "Dışa aktardığınız dosyayı şifrelemek ve bir Bitwarden hesabına içe aktarmak için kullanacağınız parolayı belirleyin." + }, + "exportTypeHeading": { + "message": "Dışa aktarma türü" + }, + "accountRestricted": { + "message": "Hesap kısıtlı" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Dosya parolası\" ile \"Dosya parolasını onaylayın\" eşleşmiyor." + }, "warning": { "message": "UYARI", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Kuruluş kasasını dışa aktarma" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Hata" }, @@ -2548,7 +2590,7 @@ "message": "Creating account on" }, "checkYourEmail": { - "message": "Check your email" + "message": "E-postanızı kontrol edin" }, "followTheLinkInTheEmailSentTo": { "message": "Follow the link in the email sent to" @@ -2560,10 +2602,10 @@ "message": "No email?" }, "goBack": { - "message": "Go back" + "message": "Geri dönüp" }, "toEditYourEmailAddress": { - "message": "to edit your email address." + "message": "e-posta adresinizi düzenleyin." }, "eu": { "message": "AB", diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 43861e99e938..a97d9626a4f8 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Розблокувати" }, + "additionalOptions": { + "message": "Додаткові налаштування" + }, "enableContextMenuItem": { "message": "Показувати в контекстному меню" }, @@ -799,12 +802,39 @@ "message": "Solarized темна", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Експортувати з" + }, "exportVault": { "message": "Експортувати сховище" }, "fileFormat": { "message": "Формат файлу" }, + "fileEncryptedExportWarningDesc": { + "message": "Цей експортований файл буде захищений паролем, який необхідно ввести для його розшифрування." + }, + "filePassword": { + "message": "Пароль файлу" + }, + "exportPasswordDescription": { + "message": "Цей пароль буде використано для експортування та імпортування цього файлу" + }, + "accountRestrictedOptionDescription": { + "message": "Використовуйте ключ шифрування свого облікового запису, створений на основі імені користувача й головного пароля, щоб зашифрувати експортовані дані та обмежити можливість імпортування лише до поточного облікового запису Bitwarden." + }, + "passwordProtectedOptionDescription": { + "message": "Встановіть пароль файлу, щоб зашифрувати експортовані дані та імпортувати до будь-якого облікового запису Bitwarden за допомогою цього пароля." + }, + "exportTypeHeading": { + "message": "Тип експорту" + }, + "accountRestricted": { + "message": "Обмежено обліковим записом" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "Пароль файлу та підтвердження пароля відрізняються." + }, "warning": { "message": "ПОПЕРЕДЖЕННЯ", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Експортування сховища організації" + }, + "exportingOrganizationVaultDesc": { + "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$. Елементи особистих сховищ або інших організацій не будуть включені.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Помилка" }, diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 3d6bd434b1a5..c8469e97dc6a 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "Mở khóa" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "Hiển thị tuỳ chọn menu ngữ cảnh" }, @@ -799,12 +802,39 @@ "message": "Solarized Dark", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Xuất kho lưu trữ" }, "fileFormat": { "message": "File Format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "CẢNH BÁO", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "Lỗi" }, diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index f8464f892f84..609275567b86 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "解锁​​​​" }, + "additionalOptions": { + "message": "附加选项" + }, "enableContextMenuItem": { "message": "显示上下文菜单选项" }, @@ -799,12 +802,39 @@ "message": "过曝暗", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "导出自" + }, "exportVault": { "message": "导出密码库" }, "fileFormat": { "message": "文件格式" }, + "fileEncryptedExportWarningDesc": { + "message": "此文件导出将受密码保护,需要文件密码才能解密。" + }, + "filePassword": { + "message": "文件密码" + }, + "exportPasswordDescription": { + "message": "此密码将用于导出和导入此文件" + }, + "accountRestrictedOptionDescription": { + "message": "使用衍生自您账户的用户名和主密码的账户加密密钥,以加密此导出并限制只能导入到当前的 Bitwarden 账户。" + }, + "passwordProtectedOptionDescription": { + "message": "设置一个文件密码用来加密此导出,并使用此密码解密以导入到任意 Bitwarden 账户。" + }, + "exportTypeHeading": { + "message": "导出类型" + }, + "accountRestricted": { + "message": "账户受限" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "「文件密码」与「确认文件密码」不一致。" + }, "warning": { "message": "警告", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "正在导出组织密码库" + }, + "exportingOrganizationVaultDesc": { + "message": "仅会导出与 $ORGANIZATION$ 关联的组织密码库数据。不包括个人密码库和其他组织中的项目。", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "错误" }, diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index a3a7bbfb6863..79d8b51382ee 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -762,6 +762,9 @@ "notificationUnlock": { "message": "解鎖" }, + "additionalOptions": { + "message": "Additional options" + }, "enableContextMenuItem": { "message": "顯示內容選單選項" }, @@ -799,12 +802,39 @@ "message": "Solarized Dark 主題", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "匯出密碼庫" }, "fileFormat": { "message": "檔案格式" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "warning": { "message": "警告", "description": "WARNING (should stay in capitalized letters if the language permits)" @@ -2182,6 +2212,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "error": { "message": "錯誤" }, diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index c9b230fe18cb..aa62194af5c5 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -77,7 +77,9 @@ type OverlayBackgroundExtensionMessageHandlers = { updateFocusedFieldData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; collectPageDetailsResponse: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; unlockCompleted: ({ message }: BackgroundMessageParam) => void; + addedCipher: () => void; addEditCipherSubmitted: () => void; + editedCipher: () => void; deletedCipher: () => void; }; diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index c469eb7dbbc1..df4867640f43 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -1000,29 +1000,23 @@ describe("OverlayBackground", () => { }); }); - describe("addEditCipherSubmitted message handler", () => { - it("updates the overlay ciphers", () => { - const message = { - command: "addEditCipherSubmitted", - }; - jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); - - sendMockExtensionMessage(message); + describe("extension messages that trigger an update of the inline menu ciphers", () => { + const extensionMessages = [ + "addedCipher", + "addEditCipherSubmitted", + "editedCipher", + "deletedCipher", + ]; - expect(overlayBackground["updateOverlayCiphers"]).toHaveBeenCalled(); + beforeEach(() => { + jest.spyOn(overlayBackground, "updateOverlayCiphers").mockImplementation(); }); - }); - describe("deletedCipher message handler", () => { - it("updates the overlay ciphers", () => { - const message = { - command: "deletedCipher", - }; - jest.spyOn(overlayBackground as any, "updateOverlayCiphers").mockImplementation(); - - sendMockExtensionMessage(message); - - expect(overlayBackground["updateOverlayCiphers"]).toHaveBeenCalled(); + extensionMessages.forEach((message) => { + it(`triggers an update of the overlay ciphers when the ${message} message is received`, () => { + sendMockExtensionMessage({ command: message }); + expect(overlayBackground.updateOverlayCiphers).toHaveBeenCalled(); + }); }); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 56f6a3a2158b..bf954c3419f7 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -72,7 +72,9 @@ class OverlayBackground implements OverlayBackgroundInterface { updateFocusedFieldData: ({ message, sender }) => this.setFocusedFieldData(message, sender), collectPageDetailsResponse: ({ message, sender }) => this.storePageDetails(message, sender), unlockCompleted: ({ message }) => this.unlockCompleted(message), + addedCipher: () => this.updateOverlayCiphers(), addEditCipherSubmitted: () => this.updateOverlayCiphers(), + editedCipher: () => this.updateOverlayCiphers(), deletedCipher: () => this.updateOverlayCiphers(), }; private readonly overlayButtonPortMessageHandlers: OverlayButtonPortMessageHandlers = { diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts index 4ea66edb3e16..302b520e336f 100644 --- a/apps/browser/src/autofill/content/autofill-init.spec.ts +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -144,7 +144,7 @@ describe("AutofillInit", () => { .mockResolvedValue(pageDetails); const response = await autofillInit["handleExtensionMessage"](message, sender, sendResponse); - await Promise.resolve(response); + await flushPromises(); expect(response).toBe(true); expect(sendResponse).toHaveBeenCalledWith(pageDetails); diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index 56c67fba0936..30e00d4e641a 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -122,6 +122,54 @@

+

{{ "additionalOptions" | i18n }}

+
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
@@ -139,5 +187,35 @@

+
+
+ + +
+
+ +
+
+ + +
+
+

diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index 1c6583331f42..7b8a1c32b449 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -4,13 +4,18 @@ import { firstValueFrom } from "rxjs"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; -import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; +import { + InlineMenuVisibilitySetting, + ClearClipboardDelaySetting, +} from "@bitwarden/common/autofill/types"; import { UriMatchStrategy, UriMatchStrategySetting, } from "@bitwarden/common/models/domain/domain-service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { DialogService } from "@bitwarden/components"; import { BrowserApi } from "../../../platform/browser/browser-api"; @@ -30,8 +35,14 @@ export class AutofillComponent implements OnInit { enableAutoFillOnPageLoad = false; autoFillOnPageLoadDefault = false; autoFillOnPageLoadOptions: any[]; + enableContextMenuItem = false; + enableAutoTotpCopy = false; // TODO: Does it matter if this is set to false or true? + clearClipboard: ClearClipboardDelaySetting; + clearClipboardOptions: any[]; defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain; uriMatchOptions: any[]; + showCardsCurrentTab = false; + showIdentitiesCurrentTab = false; autofillKeyboardHelperText: string; accountSwitcherEnabled = false; @@ -42,6 +53,8 @@ export class AutofillComponent implements OnInit { private autofillService: AutofillService, private dialogService: DialogService, private autofillSettingsService: AutofillSettingsServiceAbstraction, + private messagingService: MessagingService, + private vaultSettingsService: VaultSettingsService, ) { this.autoFillOverlayVisibilityOptions = [ { @@ -61,6 +74,15 @@ export class AutofillComponent implements OnInit { { name: i18nService.t("autoFillOnPageLoadYes"), value: true }, { name: i18nService.t("autoFillOnPageLoadNo"), value: false }, ]; + this.clearClipboardOptions = [ + { name: i18nService.t("never"), value: null }, + { name: i18nService.t("tenSeconds"), value: 10 }, + { name: i18nService.t("twentySeconds"), value: 20 }, + { name: i18nService.t("thirtySeconds"), value: 30 }, + { name: i18nService.t("oneMinute"), value: 60 }, + { name: i18nService.t("twoMinutes"), value: 120 }, + { name: i18nService.t("fiveMinutes"), value: 300 }, + ]; this.uriMatchOptions = [ { name: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain }, { name: i18nService.t("host"), value: UriMatchStrategy.Host }, @@ -95,6 +117,14 @@ export class AutofillComponent implements OnInit { this.autofillSettingsService.autofillOnPageLoadDefault$, ); + this.enableContextMenuItem = await firstValueFrom( + this.autofillSettingsService.enableContextMenu$, + ); + + this.enableAutoTotpCopy = await firstValueFrom(this.autofillSettingsService.autoCopyTotp$); + + this.clearClipboard = await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$); + const defaultUriMatch = await firstValueFrom( this.domainSettingsService.defaultUriMatchStrategy$, ); @@ -102,6 +132,12 @@ export class AutofillComponent implements OnInit { const command = await this.platformUtilsService.getAutofillKeyboardShortcut(); await this.setAutofillKeyboardHelperText(command); + + this.showCardsCurrentTab = await firstValueFrom(this.vaultSettingsService.showCardsCurrentTab$); + + this.showIdentitiesCurrentTab = await firstValueFrom( + this.vaultSettingsService.showIdentitiesCurrentTab$, + ); } async updateAutoFillOverlayVisibility() { @@ -241,4 +277,25 @@ export class AutofillComponent implements OnInit { async privacyPermissionGranted(): Promise { return await BrowserApi.permissionsGranted(["privacy"]); } + + async updateContextMenuItem() { + await this.autofillSettingsService.setEnableContextMenu(this.enableContextMenuItem); + this.messagingService.send("bgUpdateContextMenu"); + } + + async updateAutoTotpCopy() { + await this.autofillSettingsService.setAutoCopyTotp(this.enableAutoTotpCopy); + } + + async saveClearClipboard() { + await this.autofillSettingsService.setClearClipboardDelay(this.clearClipboard); + } + + async updateShowCardsCurrentTab() { + await this.vaultSettingsService.setShowCardsCurrentTab(this.showCardsCurrentTab); + } + + async updateShowIdentitiesCurrentTab() { + await this.vaultSettingsService.setShowIdentitiesCurrentTab(this.showIdentitiesCurrentTab); + } } diff --git a/apps/browser/src/autofill/utils/index.spec.ts b/apps/browser/src/autofill/utils/index.spec.ts index af67d4160155..dcb5aa646960 100644 --- a/apps/browser/src/autofill/utils/index.spec.ts +++ b/apps/browser/src/autofill/utils/index.spec.ts @@ -37,14 +37,29 @@ describe("generateRandomCustomElementName", () => { }); describe("sendExtensionMessage", () => { - it("sends a message to the extention", () => { - const extensionMessageResponse = sendExtensionMessage("updateAutofillOverlayHidden", { + it("sends a message to the extension", async () => { + const extensionMessagePromise = sendExtensionMessage("updateAutofillOverlayHidden", { display: "none", }); - jest.spyOn(chrome.runtime, "sendMessage"); - expect(chrome.runtime.sendMessage).toHaveBeenCalled(); - expect(extensionMessageResponse).toEqual(Promise.resolve({})); + // Jest doesn't give anyway to select the typed overload of "sendMessage", + // a cast is needed to get the correct spy type. + const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage") as unknown as jest.SpyInstance< + void, + [message: string, responseCallback: (response: string) => void], + unknown + >; + + expect(sendMessageSpy).toHaveBeenCalled(); + + const [latestCall] = sendMessageSpy.mock.calls; + const responseCallback = latestCall[1]; + + responseCallback("sendMessageResponse"); + + const response = await extensionMessagePromise; + + expect(response).toEqual("sendMessageResponse"); }); }); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 274649ef1303..a8654c92f032 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -9,6 +9,7 @@ import { AuthRequestService, LoginEmailServiceAbstraction, LoginEmailService, + LogoutReason, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -135,6 +136,9 @@ import { DefaultStateProvider } from "@bitwarden/common/platform/state/implement import { InlineDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/inline-derived-state"; import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service"; /* eslint-enable import/no-restricted-paths */ +import { SyncService } from "@bitwarden/common/platform/sync"; +// eslint-disable-next-line no-restricted-imports -- Needed for service creation +import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal"; import { DefaultThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; @@ -165,8 +169,6 @@ import { CollectionService as CollectionServiceAbstraction } from "@bitwarden/co import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; import { InternalFolderService as InternalFolderServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { SyncNotifierService as SyncNotifierServiceAbstraction } from "@bitwarden/common/vault/abstractions/sync/sync-notifier.service.abstraction"; -import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -175,8 +177,6 @@ import { CollectionService } from "@bitwarden/common/vault/services/collection.s import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; -import { SyncNotifierService } from "@bitwarden/common/vault/services/sync/sync-notifier.service"; -import { SyncService } from "@bitwarden/common/vault/services/sync/sync.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service"; import { @@ -267,7 +267,7 @@ export default class MainBackground { collectionService: CollectionServiceAbstraction; vaultTimeoutService: VaultTimeoutService; vaultTimeoutSettingsService: VaultTimeoutSettingsServiceAbstraction; - syncService: SyncServiceAbstraction; + syncService: SyncService; passwordGenerationService: PasswordGenerationServiceAbstraction; passwordStrengthService: PasswordStrengthServiceAbstraction; totpService: TotpServiceAbstraction; @@ -305,7 +305,6 @@ export default class MainBackground { policyApiService: PolicyApiServiceAbstraction; sendApiService: SendApiServiceAbstraction; userVerificationApiService: UserVerificationApiServiceAbstraction; - syncNotifierService: SyncNotifierServiceAbstraction; fido2UserInterfaceService: Fido2UserInterfaceServiceAbstraction; fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction; fido2ClientService: Fido2ClientServiceAbstraction; @@ -375,8 +374,17 @@ export default class MainBackground { } }; - const logoutCallback = async (expired: boolean, userId?: UserId) => - await this.logout(expired, userId); + const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) => + await this.logout(logoutReason, userId); + + const refreshAccessTokenErrorCallback = () => { + // Send toast to popup + this.messagingService.send("showToast", { + type: "error", + title: this.i18nService.t("errorRefreshingAccessToken"), + message: this.i18nService.t("errorRefreshingAccessTokenDesc"), + }); + }; const isDev = process.env.ENV === "development"; this.logService = new ConsoleLogService(isDev); @@ -523,6 +531,7 @@ export default class MainBackground { this.keyGenerationService, this.encryptService, this.logService, + logoutCallback, ); const migrationRunner = new MigrationRunner( @@ -608,9 +617,12 @@ export default class MainBackground { this.platformUtilsService, this.environmentService, this.appIdService, + refreshAccessTokenErrorCallback, + this.logService, + (logoutReason: LogoutReason, userId?: UserId) => this.logout(logoutReason, userId), this.vaultTimeoutSettingsService, - (expired: boolean) => this.logout(expired), ); + this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); this.fileUploadService = new FileUploadService(this.logService); this.cipherFileUploadService = new CipherFileUploadService( @@ -624,7 +636,6 @@ export default class MainBackground { this.i18nService, this.stateProvider, ); - this.syncNotifierService = new SyncNotifierService(); this.autofillSettingsService = new AutofillSettingsService( this.stateProvider, @@ -813,7 +824,7 @@ export default class MainBackground { messageListener, ); } else { - this.syncService = new SyncService( + this.syncService = new DefaultSyncService( this.masterPasswordService, this.accountService, this.apiService, @@ -1283,7 +1294,7 @@ export default class MainBackground { } } - async logout(expired: boolean, userId?: UserId) { + async logout(logoutReason: LogoutReason, userId?: UserId) { const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe( map((a) => a?.id), @@ -1349,7 +1360,7 @@ export default class MainBackground { await logoutPromise; this.messagingService.send("doneLoggingOut", { - expired: expired, + logoutReason: logoutReason, userId: userBeingLoggedOut, }); diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 0c1bd2905ade..623e5d1b145c 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.5.1", + "version": "2024.6.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 952396758df1..9b7dc42732f7 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.5.1", + "version": "2024.6.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/platform/popup/layout/popup-page.component.html b/apps/browser/src/platform/popup/layout/popup-page.component.html index ece6be4c63ea..b3dcd626ae3e 100644 --- a/apps/browser/src/platform/popup/layout/popup-page.component.html +++ b/apps/browser/src/platform/popup/layout/popup-page.component.html @@ -1,6 +1,6 @@ -
-
+
+
diff --git a/apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.html b/apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.html deleted file mode 100644 index 4fdbb8231209..000000000000 --- a/apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
-
-

- {{ title }} -

- -
-
- -
-
diff --git a/apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.ts b/apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.ts deleted file mode 100644 index b33a2a0f330e..000000000000 --- a/apps/browser/src/platform/popup/popup-section-header/popup-section-header.component.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Component, Input } from "@angular/core"; - -import { TypographyModule } from "@bitwarden/components"; - -@Component({ - standalone: true, - selector: "popup-section-header", - templateUrl: "./popup-section-header.component.html", - imports: [TypographyModule], -}) -export class PopupSectionHeaderComponent { - @Input() title: string; -} diff --git a/apps/browser/src/platform/popup/popup-section-header/popup-section-header.stories.ts b/apps/browser/src/platform/popup/popup-section-header/popup-section-header.stories.ts deleted file mode 100644 index f5cb472a59c2..000000000000 --- a/apps/browser/src/platform/popup/popup-section-header/popup-section-header.stories.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; - -import { - CardComponent, - IconButtonModule, - SectionComponent, - TypographyModule, -} from "@bitwarden/components"; - -import { PopupSectionHeaderComponent } from "./popup-section-header.component"; - -export default { - title: "Browser/Popup Section Header", - component: PopupSectionHeaderComponent, - args: { - title: "Title", - }, - decorators: [ - moduleMetadata({ - imports: [SectionComponent, CardComponent, TypographyModule, IconButtonModule], - }), - ], -} as Meta; - -type Story = StoryObj; - -export const OnlyTitle: Story = { - render: (args) => ({ - props: args, - template: ` - - `, - }), - args: { - title: "Only Title", - }, -}; - -export const TrailingText: Story = { - render: (args) => ({ - props: args, - template: ` - - 13 - - `, - }), - args: { - title: "Trailing Text", - }, -}; - -export const TailingIcon: Story = { - render: (args) => ({ - props: args, - template: ` - - - - `, - }), - args: { - title: "Trailing Icon", - }, -}; - -export const TitleSuffix: Story = { - render: (args) => ({ - props: args, - template: ` - - - - `, - }), - args: { - title: "Title Suffix", - }, -}; - -export const WithSections: Story = { - render: () => ({ - template: ` -
- - - - - -

Card 1 Content

-
-
- - - - - -

Card 2 Content

-
-
-
- `, - }), -}; diff --git a/apps/browser/src/popup/app-routing.animations.ts b/apps/browser/src/popup/app-routing.animations.ts index b2fa53caba8c..065331bd414b 100644 --- a/apps/browser/src/popup/app-routing.animations.ts +++ b/apps/browser/src/popup/app-routing.animations.ts @@ -196,9 +196,6 @@ export const routerTransition = trigger("routerTransition", [ transition("vault-settings => sync", inSlideLeft), transition("sync => vault-settings", outSlideRight), - transition("tabs => options", inSlideLeft), - transition("options => tabs", outSlideRight), - // Appearance settings transition("tabs => appearance", inSlideLeft), transition("appearance => tabs", outSlideRight), diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 9e0377a2f9a0..934bf0759a21 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -65,7 +65,6 @@ import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.c import { extensionRefreshRedirect, extensionRefreshSwap } from "./extension-refresh-route-utils"; import { debounceNavigationGuard } from "./services/debounce-navigation.service"; -import { OptionsComponent } from "./settings/options.component"; import { TabsV2Component } from "./tabs-v2.component"; import { TabsComponent } from "./tabs.component"; @@ -309,12 +308,6 @@ const routes: Routes = [ canActivate: [AuthGuard], data: { state: "premium" }, }, - { - path: "options", - component: OptionsComponent, - canActivate: [AuthGuard], - data: { state: "options" }, - }, { path: "appearance", component: AppearanceComponent, diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 7e94e84ef5db..b70a5564ed92 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angula import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -10,7 +11,12 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { MessageListener } from "@bitwarden/common/platform/messaging"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; +import { + DialogService, + SimpleDialogOptions, + ToastOptions, + ToastService, +} from "@bitwarden/components"; import { BrowserApi } from "../platform/browser/browser-api"; import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; @@ -83,13 +89,10 @@ export class AppComponent implements OnInit, OnDestroy { .pipe( tap((msg: any) => { if (msg.command === "doneLoggingOut") { + // TODO: PM-8544 - why do we call logout in the popup after receiving the doneLoggingOut message? Hasn't this already completeted logout? this.authService.logOut(async () => { - if (msg.expired) { - this.toastService.showToast({ - variant: "warning", - title: this.i18nService.t("loggedOut"), - message: this.i18nService.t("loginExpired"), - }); + if (msg.logoutReason) { + await this.displayLogoutReason(msg.logoutReason); } }); this.changeDetectorRef.detectChanges(); @@ -233,4 +236,23 @@ export class AppComponent implements OnInit, OnDestroy { this.browserSendStateService.setBrowserSendTypeComponentState(null), ]); } + + // Displaying toasts isn't super useful on the popup due to the reloads we do. + // However, it is visible for a moment on the FF sidebar logout. + private async displayLogoutReason(logoutReason: LogoutReason) { + let toastOptions: ToastOptions; + switch (logoutReason) { + case "invalidSecurityStamp": + case "sessionExpired": { + toastOptions = { + variant: "warning", + title: this.i18nService.t("loggedOut"), + message: this.i18nService.t("loginExpired"), + }; + break; + } + } + + this.toastService.showToast(toastOptions); + } } diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index e16c33de3ae2..3c7f45e55f76 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -46,7 +46,6 @@ import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.comp import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../platform/popup/layout/popup-page.component"; import { PopupTabNavigationComponent } from "../platform/popup/layout/popup-tab-navigation.component"; -import { PopupSectionHeaderComponent } from "../platform/popup/popup-section-header/popup-section-header.component"; import { FilePopoutCalloutComponent } from "../tools/popup/components/file-popout-callout.component"; import { GeneratorComponent } from "../tools/popup/generator/generator.component"; import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/password-generator-history.component"; @@ -82,7 +81,6 @@ import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; import { UserVerificationComponent } from "./components/user-verification.component"; import { ServicesModule } from "./services/services.module"; -import { OptionsComponent } from "./settings/options.component"; import { TabsV2Component } from "./tabs-v2.component"; import { TabsComponent } from "./tabs.component"; @@ -120,7 +118,6 @@ import "../platform/popup/locales"; PopupFooterComponent, PopupHeaderComponent, UserVerificationDialogComponent, - PopupSectionHeaderComponent, CurrentAccountComponent, ], declarations: [ @@ -149,7 +146,6 @@ import "../platform/popup/locales"; LoginComponent, LoginViaAuthRequestComponent, LoginDecryptionOptionsComponent, - OptionsComponent, NotificationsSettingsComponent, AppearanceComponent, GeneratorComponent, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index ace9af3dfa80..d61fa3b19c02 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -80,11 +80,11 @@ import { } from "@bitwarden/common/platform/state"; // eslint-disable-next-line import/no-restricted-paths -- Used for dependency injection import { InlineDerivedStateProvider } from "@bitwarden/common/platform/state/implementations/inline-derived-state"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { FolderService as FolderServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { DialogService, ToastService } from "@bitwarden/components"; diff --git a/apps/browser/src/popup/settings/options.component.html b/apps/browser/src/popup/settings/options.component.html deleted file mode 100644 index 0382eb5b8665..000000000000 --- a/apps/browser/src/popup/settings/options.component.html +++ /dev/null @@ -1,135 +0,0 @@ -
-
- -
-

- {{ "options" | i18n }} -

-
-
-
-
-

- -

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

- -

-
- -
-
-
- - -
-
- -
-
-
-
- - -
-
- -
-
-
diff --git a/apps/browser/src/popup/settings/options.component.ts b/apps/browser/src/popup/settings/options.component.ts deleted file mode 100644 index cfcc81bb22c6..000000000000 --- a/apps/browser/src/popup/settings/options.component.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Component, OnInit } from "@angular/core"; -import { firstValueFrom } from "rxjs"; - -import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; -import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; -import { ClearClipboardDelaySetting } from "@bitwarden/common/autofill/types"; -import { - UriMatchStrategy, - UriMatchStrategySetting, -} from "@bitwarden/common/models/domain/domain-service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; - -import { enableAccountSwitching } from "../../platform/flags"; - -@Component({ - selector: "app-options", - templateUrl: "options.component.html", -}) -export class OptionsComponent implements OnInit { - enableAutoFillOnPageLoad = false; - autoFillOnPageLoadDefault = false; - autoFillOnPageLoadOptions: any[]; - enableAutoTotpCopy = false; // TODO: Does it matter if this is set to false or true? - enableContextMenuItem = false; - showCardsCurrentTab = false; - showIdentitiesCurrentTab = false; - showClearClipboard = true; - defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain; - uriMatchOptions: any[]; - clearClipboard: ClearClipboardDelaySetting; - clearClipboardOptions: any[]; - showGeneral = true; - showDisplay = true; - accountSwitcherEnabled = false; - - constructor( - private messagingService: MessagingService, - private autofillSettingsService: AutofillSettingsServiceAbstraction, - private domainSettingsService: DomainSettingsService, - i18nService: I18nService, - private vaultSettingsService: VaultSettingsService, - ) { - this.uriMatchOptions = [ - { name: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain }, - { name: i18nService.t("host"), value: UriMatchStrategy.Host }, - { name: i18nService.t("startsWith"), value: UriMatchStrategy.StartsWith }, - { name: i18nService.t("regEx"), value: UriMatchStrategy.RegularExpression }, - { name: i18nService.t("exact"), value: UriMatchStrategy.Exact }, - { name: i18nService.t("never"), value: UriMatchStrategy.Never }, - ]; - this.clearClipboardOptions = [ - { name: i18nService.t("never"), value: null }, - { name: i18nService.t("tenSeconds"), value: 10 }, - { name: i18nService.t("twentySeconds"), value: 20 }, - { name: i18nService.t("thirtySeconds"), value: 30 }, - { name: i18nService.t("oneMinute"), value: 60 }, - { name: i18nService.t("twoMinutes"), value: 120 }, - { name: i18nService.t("fiveMinutes"), value: 300 }, - ]; - this.autoFillOnPageLoadOptions = [ - { name: i18nService.t("autoFillOnPageLoadYes"), value: true }, - { name: i18nService.t("autoFillOnPageLoadNo"), value: false }, - ]; - - this.accountSwitcherEnabled = enableAccountSwitching(); - } - - async ngOnInit() { - this.enableAutoFillOnPageLoad = await firstValueFrom( - this.autofillSettingsService.autofillOnPageLoad$, - ); - - this.autoFillOnPageLoadDefault = await firstValueFrom( - this.autofillSettingsService.autofillOnPageLoadDefault$, - ); - - this.enableContextMenuItem = await firstValueFrom( - this.autofillSettingsService.enableContextMenu$, - ); - - this.showCardsCurrentTab = await firstValueFrom(this.vaultSettingsService.showCardsCurrentTab$); - this.showIdentitiesCurrentTab = await firstValueFrom( - this.vaultSettingsService.showIdentitiesCurrentTab$, - ); - - this.enableAutoTotpCopy = await firstValueFrom(this.autofillSettingsService.autoCopyTotp$); - - const defaultUriMatch = await firstValueFrom( - this.domainSettingsService.defaultUriMatchStrategy$, - ); - this.defaultUriMatch = defaultUriMatch == null ? UriMatchStrategy.Domain : defaultUriMatch; - - this.clearClipboard = await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$); - } - - async updateContextMenuItem() { - await this.autofillSettingsService.setEnableContextMenu(this.enableContextMenuItem); - this.messagingService.send("bgUpdateContextMenu"); - } - - async updateAutoTotpCopy() { - await this.autofillSettingsService.setAutoCopyTotp(this.enableAutoTotpCopy); - } - - async updateAutoFillOnPageLoad() { - await this.autofillSettingsService.setAutofillOnPageLoad(this.enableAutoFillOnPageLoad); - } - - async updateAutoFillOnPageLoadDefault() { - await this.autofillSettingsService.setAutofillOnPageLoadDefault(this.autoFillOnPageLoadDefault); - } - - async updateShowCardsCurrentTab() { - await this.vaultSettingsService.setShowCardsCurrentTab(this.showCardsCurrentTab); - } - - async updateShowIdentitiesCurrentTab() { - await this.vaultSettingsService.setShowIdentitiesCurrentTab(this.showIdentitiesCurrentTab); - } - - async saveClearClipboard() { - await this.autofillSettingsService.setClearClipboardDelay(this.clearClipboard); - } -} diff --git a/apps/browser/src/tools/popup/settings/settings.component.html b/apps/browser/src/tools/popup/settings/settings.component.html index 7dba3d0a3de5..c547229653e1 100644 --- a/apps/browser/src/tools/popup/settings/settings.component.html +++ b/apps/browser/src/tools/popup/settings/settings.component.html @@ -42,14 +42,6 @@

{{ "vault" | i18n }}
- - + {{ "autofillSuggestionsTip" | i18n }} diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts index c00e585e7390..1b9876759f0a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.ts @@ -3,12 +3,16 @@ import { Component } from "@angular/core"; import { combineLatest, map, Observable } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { IconButtonModule, SectionComponent, TypographyModule } from "@bitwarden/components"; +import { + IconButtonModule, + SectionComponent, + SectionHeaderComponent, + TypographyModule, +} from "@bitwarden/components"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; -import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { PopupCipherView } from "../../../views/popup-cipher.view"; import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component"; @Component({ @@ -19,7 +23,7 @@ import { VaultListItemsContainerComponent } from "../vault-list-items-container/ TypographyModule, VaultListItemsContainerComponent, JslibModule, - PopupSectionHeaderComponent, + SectionHeaderComponent, IconButtonModule, ], selector: "app-autofill-vault-list-items", @@ -30,7 +34,7 @@ export class AutofillVaultListItemsComponent { * The list of ciphers that can be used to autofill the current page. * @protected */ - protected autofillCiphers$: Observable = + protected autofillCiphers$: Observable = this.vaultPopupItemsService.autoFillCiphers$; /** diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html new file mode 100644 index 000000000000..08133c6b466b --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts new file mode 100644 index 000000000000..c89fcca3b3f1 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-copy-action/item-copy-actions.component.ts @@ -0,0 +1,29 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components"; +import { CopyCipherFieldDirective } from "@bitwarden/vault"; + +@Component({ + standalone: true, + selector: "app-item-copy-actions", + templateUrl: "item-copy-actions.component.html", + imports: [ + ItemModule, + IconButtonModule, + JslibModule, + MenuModule, + CommonModule, + CopyCipherFieldDirective, + ], +}) +export class ItemCopyActionsComponent { + @Input() cipher: CipherView; + + protected CipherType = CipherType; + + constructor() {} +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html new file mode 100644 index 000000000000..1d7a2a8cd0cd --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + {{ "clone" | i18n }} + + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts new file mode 100644 index 000000000000..9834dc553ecf --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -0,0 +1,122 @@ +import { CommonModule } from "@angular/common"; +import { booleanAttribute, Component, Input } from "@angular/core"; +import { Router, RouterModule } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService, IconButtonModule, ItemModule, MenuModule } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { BrowserApi } from "../../../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; + +@Component({ + standalone: true, + selector: "app-item-more-options", + templateUrl: "./item-more-options.component.html", + imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], +}) +export class ItemMoreOptionsComponent { + @Input({ + required: true, + }) + cipher: CipherView; + + /** + * Flag to hide the login specific menu options. Used for login items that are + * already in the autofill list suggestion. + */ + @Input({ transform: booleanAttribute }) + hideLoginOptions: boolean; + + protected autofillAllowed$ = this.vaultPopupItemsService.autofillAllowed$; + + constructor( + private cipherService: CipherService, + private vaultPopupItemsService: VaultPopupItemsService, + private passwordRepromptService: PasswordRepromptService, + private dialogService: DialogService, + private router: Router, + ) {} + + get canEdit() { + return this.cipher.edit; + } + + get isLogin() { + return this.cipher.type === CipherType.Login; + } + + get favoriteText() { + return this.cipher.favorite ? "unfavorite" : "favorite"; + } + + /** + * Determines if the login cipher can be launched in a new browser tab. + */ + get canLaunch() { + return this.isLogin && this.cipher.login.canLaunch; + } + + /** + * Launches the login cipher in a new browser tab. + */ + async launchCipher() { + if (!this.canLaunch) { + return; + } + + await this.cipherService.updateLastLaunchedDate(this.cipher.id); + + await BrowserApi.createNewTab(this.cipher.login.launchUri); + + if (BrowserPopupUtils.inPopup(window)) { + BrowserApi.closePopup(window); + } + } + + /** + * Toggles the favorite status of the cipher and updates it on the server. + */ + async toggleFavorite() { + this.cipher.favorite = !this.cipher.favorite; + const encryptedCipher = await this.cipherService.encrypt(this.cipher); + await this.cipherService.updateWithServer(encryptedCipher); + } + + /** + * Navigate to the clone cipher page with the current cipher as the source. + * A password reprompt is attempted if the cipher requires it. + * A confirmation dialog is shown if the cipher has FIDO2 credentials. + */ + async clone() { + if ( + this.cipher.reprompt === CipherRepromptType.Password && + !(await this.passwordRepromptService.showPasswordPrompt()) + ) { + return; + } + + if (this.cipher.login?.hasFido2Credentials) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "passkeyNotCopied" }, + content: { key: "passkeyNotCopiedAlert" }, + type: "info", + }); + + if (!confirmed) { + return; + } + } + + await this.router.navigate(["/clone-cipher"], { + queryParams: { + cloneMode: true, + cipherId: this.cipher.id, + }, + }); + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html new file mode 100644 index 000000000000..afb7dd400661 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html @@ -0,0 +1,39 @@ +
+ + + + + + + + + + + + + + +
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts new file mode 100644 index 000000000000..886e1a966a8c --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts @@ -0,0 +1,28 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnDestroy } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ChipSelectComponent } from "@bitwarden/components"; + +import { VaultPopupListFiltersService } from "../../../services/vault-popup-list-filters.service"; + +@Component({ + standalone: true, + selector: "app-vault-list-filters", + templateUrl: "./vault-list-filters.component.html", + imports: [CommonModule, JslibModule, ChipSelectComponent, ReactiveFormsModule], +}) +export class VaultListFiltersComponent implements OnDestroy { + protected filterForm = this.vaultPopupListFiltersService.filterForm; + protected organizations$ = this.vaultPopupListFiltersService.organizations$; + protected collections$ = this.vaultPopupListFiltersService.collections$; + protected folders$ = this.vaultPopupListFiltersService.folders$; + protected cipherTypes = this.vaultPopupListFiltersService.cipherTypes; + + constructor(private vaultPopupListFiltersService: VaultPopupListFiltersService) {} + + ngOnDestroy(): void { + this.vaultPopupListFiltersService.resetFilterForm(); + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index d3bb85c710f4..c2c345fd7573 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -1,43 +1,45 @@ - - {{ ciphers.length }} + +

+ {{ title }} +

-
+ {{ ciphers.length }} + - + {{ cipher.name }} + {{ cipher.subTitle }} - + - - - - - - + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index f9b34e96162b..59e3a3c3a1a7 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -3,17 +3,20 @@ import { booleanAttribute, Component, EventEmitter, Input, Output } from "@angul import { RouterLink } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { BadgeModule, ButtonModule, IconButtonModule, ItemModule, SectionComponent, + SectionHeaderComponent, TypographyModule, } from "@bitwarden/components"; -import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup-section-header/popup-section-header.component"; +import { PopupCipherView } from "../../../views/popup-cipher.view"; +import { ItemCopyActionsComponent } from "../item-copy-action/item-copy-actions.component"; +import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component"; @Component({ imports: [ @@ -25,8 +28,10 @@ import { PopupSectionHeaderComponent } from "../../../../../platform/popup/popup SectionComponent, TypographyModule, JslibModule, - PopupSectionHeaderComponent, + SectionHeaderComponent, RouterLink, + ItemCopyActionsComponent, + ItemMoreOptionsComponent, ], selector: "app-vault-list-items-container", templateUrl: "vault-list-items-container.component.html", @@ -37,7 +42,7 @@ export class VaultListItemsContainerComponent { * The list of ciphers to display. */ @Input() - ciphers: CipherView[]; + ciphers: PopupCipherView[] = []; /** * Title for the vault list item section. @@ -61,5 +66,19 @@ export class VaultListItemsContainerComponent { * Option to show the autofill button for each item. */ @Input({ transform: booleanAttribute }) - showAutoFill: boolean; + showAutofillButton: boolean; + + /** + * The tooltip text for the organization icon for ciphers that belong to an organization. + * @param cipher + */ + orgIconTooltip(cipher: PopupCipherView) { + if (cipher.collectionIds.length > 1) { + return this.i18nService.t("nCollections", cipher.collectionIds.length); + } + + return cipher.collections[0]?.name; + } + + constructor(private i18nService: I18nService) {} } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts index 321717285a93..e61003216073 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts @@ -1,12 +1,14 @@ import { CommonModule } from "@angular/common"; -import { Component, Output, EventEmitter } from "@angular/core"; +import { Component } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; -import { Subject, debounceTime } from "rxjs"; +import { Subject, Subscription, debounceTime, filter } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SearchModule } from "@bitwarden/components"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; + const SearchTextDebounceInterval = 200; @Component({ @@ -17,19 +19,34 @@ const SearchTextDebounceInterval = 200; }) export class VaultV2SearchComponent { searchText: string; - @Output() searchTextChanged = new EventEmitter(); private searchText$ = new Subject(); - constructor() { - this.searchText$ - .pipe(debounceTime(SearchTextDebounceInterval), takeUntilDestroyed()) - .subscribe((data) => { - this.searchTextChanged.emit(data); - }); + constructor(private vaultPopupItemsService: VaultPopupItemsService) { + this.subscribeToLatestSearchText(); + this.subscribeToApplyFilter(); } onSearchTextChanged() { this.searchText$.next(this.searchText); } + + subscribeToLatestSearchText(): Subscription { + return this.vaultPopupItemsService.latestSearchText$ + .pipe( + takeUntilDestroyed(), + filter((data) => !!data), + ) + .subscribe((text) => { + this.searchText = text; + }); + } + + subscribeToApplyFilter(): Subscription { + return this.searchText$ + .pipe(debounceTime(SearchTextDebounceInterval), takeUntilDestroyed()) + .subscribe((data) => { + this.vaultPopupItemsService.applyFilter(data); + }); + } } diff --git a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts index 4d2674fd7030..24ca030284fb 100644 --- a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts +++ b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts @@ -14,8 +14,8 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co 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"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; 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 deb4434df47b..b46b4cf9ff2c 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 @@ -9,8 +9,8 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html index 7d83d9f26ccf..9f38fd61fab6 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html @@ -22,22 +22,31 @@

- + - - +
- + {{ "noItemsMatchSearch" | i18n }} {{ "clearFiltersOrTryAnother" | i18n }}
- +
+ + {{ "organizationIsDeactivated" | i18n }} + {{ "contactYourOrgAdmin" | i18n }} + +
+ + { + let testBed: TestBed; let service: VaultPopupItemsService; let allCiphers: Record; let autoFillCiphers: CipherView[]; + let mockOrg: Organization; + let mockCollections: CollectionView[]; + const cipherServiceMock = mock(); const vaultSettingsServiceMock = mock(); + const organizationServiceMock = mock(); + const vaultPopupListFiltersServiceMock = mock(); const searchService = mock(); + const collectionService = mock(); beforeEach(() => { allCiphers = cipherFactory(10); @@ -35,31 +50,109 @@ describe("VaultPopupItemsService", () => { cipherList[2].favorite = true; cipherList[3].favorite = true; - cipherServiceMock.cipherViews$ = new BehaviorSubject(allCiphers).asObservable(); - searchService.searchCiphers.mockImplementation(async () => cipherList); - cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers); - vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable(); - vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable(); + cipherServiceMock.getAllDecrypted.mockResolvedValue(cipherList); + cipherServiceMock.ciphers$ = new BehaviorSubject(null); + cipherServiceMock.localData$ = new BehaviorSubject(null); + searchService.searchCiphers.mockImplementation(async (_, __, ciphers) => ciphers); + cipherServiceMock.filterCiphersForUrl.mockImplementation(async (ciphers) => + ciphers.filter((c) => ["0", "1"].includes(c.id)), + ); + vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false); + vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false); + + vaultPopupListFiltersServiceMock.filters$ = new BehaviorSubject({ + organization: null, + collection: null, + cipherType: null, + folder: null, + }); + // Return all ciphers, `filterFunction$` will be tested in `VaultPopupListFiltersService` + vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject( + (ciphers: CipherView[]) => ciphers, + ); jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); jest .spyOn(BrowserApi, "getTabFromCurrentWindow") .mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab); - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - searchService, - ); + + mockOrg = { + id: "org1", + name: "Organization 1", + planProductType: ProductType.Enterprise, + } as Organization; + + mockCollections = [ + { id: "col1", name: "Collection 1" } as CollectionView, + { id: "col2", name: "Collection 2" } as CollectionView, + ]; + + organizationServiceMock.organizations$ = new BehaviorSubject([mockOrg]); + collectionService.decryptedCollections$ = new BehaviorSubject(mockCollections); + + testBed = TestBed.configureTestingModule({ + providers: [ + { provide: CipherService, useValue: cipherServiceMock }, + { provide: VaultSettingsService, useValue: vaultSettingsServiceMock }, + { provide: SearchService, useValue: searchService }, + { provide: OrganizationService, useValue: organizationServiceMock }, + { provide: VaultPopupListFiltersService, useValue: vaultPopupListFiltersServiceMock }, + { provide: CollectionService, useValue: collectionService }, + ], + }); + + service = testBed.inject(VaultPopupItemsService); + }); + + afterEach(() => { + jest.clearAllMocks(); }); it("should be created", () => { - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - searchService, - ); + service = testBed.inject(VaultPopupItemsService); expect(service).toBeTruthy(); }); + it("should merge cipher views with collections and organization", (done) => { + const cipherList = Object.values(allCiphers); + cipherList[0].organizationId = "org1"; + cipherList[0].collectionIds = ["col1", "col2"]; + + service.autoFillCiphers$.subscribe((ciphers) => { + expect(ciphers[0].organization).toEqual(mockOrg); + expect(ciphers[0].collections).toContain(mockCollections[0]); + expect(ciphers[0].collections).toContain(mockCollections[1]); + done(); + }); + }); + + it("should update cipher list when cipherService.ciphers$ emits", async () => { + const tracker = new ObservableTracker(service.autoFillCiphers$); + + await tracker.expectEmission(); + + (cipherServiceMock.ciphers$ as BehaviorSubject).next(null); + + await tracker.expectEmission(); + + // Should only emit twice + expect(tracker.emissions.length).toBe(2); + await expect(tracker.pauseUntilReceived(3)).rejects.toThrow("Timeout exceeded"); + }); + + it("should update cipher list when cipherService.localData$ emits", async () => { + const tracker = new ObservableTracker(service.autoFillCiphers$); + + await tracker.expectEmission(); + + (cipherServiceMock.localData$ as BehaviorSubject).next(null); + + await tracker.expectEmission(); + + // Should only emit twice + expect(tracker.emissions.length).toBe(2); + await expect(tracker.pauseUntilReceived(3)).rejects.toThrow("Timeout exceeded"); + }); + describe("autoFillCiphers$", () => { it("should return empty array if there is no current tab", (done) => { jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(null); @@ -80,16 +173,10 @@ describe("VaultPopupItemsService", () => { it("should filter ciphers for the current tab and types", (done) => { const currentTab = { url: "https://example.com" } as chrome.tabs.Tab; - vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(true).asObservable(); - vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(true).asObservable(); + (vaultSettingsServiceMock.showCardsCurrentTab$ as BehaviorSubject).next(true); + (vaultSettingsServiceMock.showIdentitiesCurrentTab$ as BehaviorSubject).next(true); jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab); - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - searchService, - ); - service.autoFillCiphers$.subscribe((ciphers) => { expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1); expect(cipherServiceMock.filterCiphersForUrl).toHaveBeenCalledWith( @@ -114,12 +201,6 @@ describe("VaultPopupItemsService", () => { Object.values(allCiphers), ); - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - searchService, - ); - service.autoFillCiphers$.subscribe((ciphers) => { expect(ciphers.length).toBe(10); @@ -135,19 +216,18 @@ describe("VaultPopupItemsService", () => { }); it("should filter autoFillCiphers$ down to search term", (done) => { - const cipherList = Object.values(allCiphers); const searchText = "Login"; - searchService.searchCiphers.mockImplementation(async () => { - return cipherList.filter((cipher) => { + searchService.searchCiphers.mockImplementation(async (q, _, ciphers) => { + return ciphers.filter((cipher) => { return cipher.name.includes(searchText); }); }); - // there is only 1 Login returned for filteredCiphers. but two results expected because of other autofill types + // there is only 1 Login returned for filteredCiphers. service.autoFillCiphers$.subscribe((ciphers) => { expect(ciphers[0].name.includes(searchText)).toBe(true); - expect(ciphers.length).toBe(2); + expect(ciphers.length).toBe(1); done(); }); }); @@ -224,12 +304,7 @@ describe("VaultPopupItemsService", () => { describe("emptyVault$", () => { it("should return true if there are no ciphers", (done) => { - cipherServiceMock.cipherViews$ = new BehaviorSubject({}).asObservable(); - service = new VaultPopupItemsService( - cipherServiceMock, - vaultSettingsServiceMock, - searchService, - ); + cipherServiceMock.getAllDecrypted.mockResolvedValue([]); service.emptyVault$.subscribe((empty) => { expect(empty).toBe(true); done(); diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 9a66ada08c5f..189ce2c09f99 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -1,8 +1,11 @@ -import { Injectable } from "@angular/core"; +import { inject, Injectable, NgZone } from "@angular/core"; import { BehaviorSubject, combineLatest, + distinctUntilKeyChanged, + from, map, + merge, Observable, of, shareReplay, @@ -12,13 +15,21 @@ import { } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BrowserApi } from "../../../platform/browser/browser-api"; +import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; +import { PopupCipherView } from "../views/popup-cipher.view"; + +import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; /** * Service for managing the various item lists on the new Vault tab in the browser popup. @@ -28,7 +39,8 @@ import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; }) export class VaultPopupItemsService { private _refreshCurrentTab$ = new Subject(); - private searchText$ = new BehaviorSubject(""); + private _searchText$ = new BehaviorSubject(""); + latestSearchText$: Observable = this._searchText$.asObservable(); /** * Observable that contains the list of other cipher types that should be shown @@ -67,14 +79,46 @@ export class VaultPopupItemsService { * Observable that contains the list of all decrypted ciphers. * @private */ - private _cipherList$: Observable = this.cipherService.cipherViews$.pipe( - map((ciphers) => Object.values(ciphers)), - shareReplay({ refCount: false, bufferSize: 1 }), + private _cipherList$: Observable = merge( + this.cipherService.ciphers$, + this.cipherService.localData$, + ).pipe( + runInsideAngular(inject(NgZone)), // Workaround to ensure cipher$ state provider emissions are run inside Angular + switchMap(() => Utils.asyncToObservable(() => this.cipherService.getAllDecrypted())), + switchMap((ciphers) => + combineLatest([ + this.organizationService.organizations$, + this.collectionService.decryptedCollections$, + ]).pipe( + map(([organizations, collections]) => { + const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org])); + const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col])); + return ciphers.map( + (cipher) => + new PopupCipherView( + cipher, + cipher.collectionIds?.map((colId) => collectionMap[colId as CollectionId]), + orgMap[cipher.organizationId as OrganizationId], + ), + ); + }), + ), + ), + shareReplay({ refCount: true, bufferSize: 1 }), ); - private _filteredCipherList$ = combineLatest([this._cipherList$, this.searchText$]).pipe( - switchMap(([ciphers, searchText]) => - this.searchService.searchCiphers(searchText, null, ciphers), + private _filteredCipherList$: Observable = combineLatest([ + this._cipherList$, + this._searchText$, + this.vaultPopupListFiltersService.filterFunction$, + ]).pipe( + map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [ + filterFunction(ciphers), + searchText, + ]), + switchMap( + ([ciphers, searchText]) => + this.searchService.searchCiphers(searchText, null, ciphers) as Promise, ), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -85,7 +129,7 @@ export class VaultPopupItemsService { * * See {@link refreshCurrentTab} to trigger re-evaluation of the current tab. */ - autoFillCiphers$: Observable = combineLatest([ + autoFillCiphers$: Observable = combineLatest([ this._filteredCipherList$, this._otherAutoFillTypes$, this._currentAutofillTab$, @@ -104,7 +148,7 @@ export class VaultPopupItemsService { * List of favorite ciphers that are not currently suggested for autofill. * Ciphers are sorted by last used date, then by name. */ - favoriteCiphers$: Observable = combineLatest([ + favoriteCiphers$: Observable = combineLatest([ this.autoFillCiphers$, this._filteredCipherList$, ]).pipe( @@ -121,7 +165,7 @@ export class VaultPopupItemsService { * List of all remaining ciphers that are not currently suggested for autofill or marked as favorite. * Ciphers are sorted by name. */ - remainingCiphers$: Observable = combineLatest([ + remainingCiphers$: Observable = combineLatest([ this.autoFillCiphers$, this.favoriteCiphers$, this._filteredCipherList$, @@ -137,10 +181,19 @@ export class VaultPopupItemsService { /** * Observable that indicates whether a filter is currently applied to the ciphers. - * @todo Implement filter/search functionality in PM-6824 and PM-6826. */ - hasFilterApplied$: Observable = this.searchText$.pipe( - switchMap((text) => this.searchService.isSearchable(text)), + hasFilterApplied$ = combineLatest([ + this._searchText$, + this.vaultPopupListFiltersService.filters$, + ]).pipe( + switchMap(([searchText, filters]) => { + return from(this.searchService.isSearchable(searchText)).pipe( + map( + (isSearchable) => + isSearchable || Object.values(filters).some((filter) => filter !== null), + ), + ); + }), ); /** @@ -156,16 +209,33 @@ export class VaultPopupItemsService { /** * Observable that indicates whether there are no ciphers to show with the current filter. - * @todo Implement filter/search functionality in PM-6824 and PM-6826. */ noFilteredResults$: Observable = this._filteredCipherList$.pipe( map((ciphers) => !ciphers.length), ); + /** Observable that indicates when the user should see the deactivated org state */ + showDeactivatedOrg$: Observable = combineLatest([ + this.vaultPopupListFiltersService.filters$.pipe(distinctUntilKeyChanged("organization")), + this.organizationService.organizations$, + ]).pipe( + map(([filters, orgs]) => { + if (!filters.organization || filters.organization.id === MY_VAULT_ID) { + return false; + } + + const org = orgs.find((o) => o.id === filters.organization.id); + return org ? !org.enabled : false; + }), + ); + constructor( private cipherService: CipherService, private vaultSettingsService: VaultSettingsService, + private vaultPopupListFiltersService: VaultPopupListFiltersService, + private organizationService: OrganizationService, private searchService: SearchService, + private collectionService: CollectionService, ) {} /** @@ -176,7 +246,7 @@ export class VaultPopupItemsService { } applyFilter(newSearchText: string) { - this.searchText$.next(newSearchText); + this._searchText$.next(newSearchText); } /** diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts new file mode 100644 index 000000000000..42626b52918d --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -0,0 +1,321 @@ +import { TestBed } from "@angular/core/testing"; +import { FormBuilder } from "@angular/forms"; +import { BehaviorSubject, skipWhile } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { Collection } from "@bitwarden/common/vault/models/domain/collection"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; + +import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; + +describe("VaultPopupListFiltersService", () => { + let service: VaultPopupListFiltersService; + const memberOrganizations$ = new BehaviorSubject<{ name: string; id: string }[]>([]); + const folderViews$ = new BehaviorSubject([]); + const cipherViews$ = new BehaviorSubject({}); + const decryptedCollections$ = new BehaviorSubject([]); + + const collectionService = { + decryptedCollections$, + getAllNested: () => Promise.resolve([]), + } as unknown as CollectionService; + + const folderService = { + folderViews$, + } as unknown as FolderService; + + const cipherService = { + cipherViews$, + } as unknown as CipherService; + + const organizationService = { + memberOrganizations$, + } as unknown as OrganizationService; + + const i18nService = { + t: (key: string) => key, + } as I18nService; + + beforeEach(() => { + memberOrganizations$.next([]); + decryptedCollections$.next([]); + + collectionService.getAllNested = () => Promise.resolve([]); + TestBed.configureTestingModule({ + providers: [ + { + provide: FolderService, + useValue: folderService, + }, + { + provide: CipherService, + useValue: cipherService, + }, + { + provide: OrganizationService, + useValue: organizationService, + }, + { + provide: I18nService, + useValue: i18nService, + }, + { + provide: CollectionService, + useValue: collectionService, + }, + { provide: FormBuilder, useClass: FormBuilder }, + ], + }); + + service = TestBed.inject(VaultPopupListFiltersService); + }); + + describe("cipherTypes", () => { + it("returns all cipher types", () => { + expect(service.cipherTypes.map((c) => c.value)).toEqual([ + CipherType.Login, + CipherType.Card, + CipherType.Identity, + CipherType.SecureNote, + ]); + }); + }); + + describe("organizations$", () => { + it('does not add "myVault" to the list of organizations when there are no organizations', (done) => { + memberOrganizations$.next([]); + + service.organizations$.subscribe((organizations) => { + expect(organizations.map((o) => o.label)).toEqual([]); + done(); + }); + }); + + it('adds "myVault" to the list of organizations when there are other organizations', (done) => { + memberOrganizations$.next([{ name: "bobby's org", id: "1234-3323-23223" }]); + + service.organizations$.subscribe((organizations) => { + expect(organizations.map((o) => o.label)).toEqual(["myVault", "bobby's org"]); + done(); + }); + }); + + it("sorts organizations by name", (done) => { + memberOrganizations$.next([ + { name: "bobby's org", id: "1234-3323-23223" }, + { name: "alice's org", id: "2223-4343-99888" }, + ]); + + service.organizations$.subscribe((organizations) => { + expect(organizations.map((o) => o.label)).toEqual([ + "myVault", + "alice's org", + "bobby's org", + ]); + done(); + }); + }); + }); + + describe("collections$", () => { + const testCollection = { + id: "14cbf8e9-7a2a-4105-9bf6-b15c01203cef", + name: "Test collection", + organizationId: "3f860945-b237-40bc-a51e-b15c01203ccf", + } as CollectionView; + + const testCollection2 = { + id: "b15c0120-7a2a-4105-9bf6-b15c01203ceg", + name: "Test collection 2", + organizationId: "1203ccf-2432-123-acdd-b15c01203ccf", + } as CollectionView; + + const testCollections = [testCollection, testCollection2]; + + beforeEach(() => { + decryptedCollections$.next(testCollections); + + collectionService.getAllNested = () => + Promise.resolve( + testCollections.map((c) => ({ + children: [], + node: c, + parent: null, + })), + ); + }); + + it("returns all collections", (done) => { + service.collections$.subscribe((collections) => { + expect(collections.map((c) => c.label)).toEqual(["Test collection", "Test collection 2"]); + done(); + }); + }); + + it("filters out collections that do not belong to an organization", () => { + service.filterForm.patchValue({ + organization: { id: testCollection2.organizationId } as Organization, + }); + + service.collections$.subscribe((collections) => { + expect(collections.map((c) => c.label)).toEqual(["Test collection 2"]); + }); + }); + + it("sets collection icon", (done) => { + service.collections$.subscribe((collections) => { + expect(collections.every(({ icon }) => icon === "bwi-collection")).toBeTruthy(); + done(); + }); + }); + }); + + describe("folders$", () => { + it('returns no folders when "No Folder" is the only option', (done) => { + folderViews$.next([{ id: null, name: "No Folder" }]); + + service.folders$.subscribe((folders) => { + expect(folders).toEqual([]); + done(); + }); + }); + + it('moves "No Folder" to the end of the list', (done) => { + folderViews$.next([ + { id: null, name: "No Folder" }, + { id: "2345", name: "Folder 2" }, + { id: "1234", name: "Folder 1" }, + ]); + + service.folders$.subscribe((folders) => { + expect(folders.map((f) => f.label)).toEqual(["Folder 1", "Folder 2", "itemsWithNoFolder"]); + done(); + }); + }); + + it("returns all folders when MyVault is selected", (done) => { + service.filterForm.patchValue({ + organization: { id: MY_VAULT_ID } as Organization, + }); + + folderViews$.next([ + { id: "1234", name: "Folder 1" }, + { id: "2345", name: "Folder 2" }, + ]); + + service.folders$.subscribe((folders) => { + expect(folders.map((f) => f.label)).toEqual(["Folder 1", "Folder 2"]); + done(); + }); + }); + + it("sets folder icon", (done) => { + service.filterForm.patchValue({ + organization: { id: MY_VAULT_ID } as Organization, + }); + + folderViews$.next([ + { id: "1234", name: "Folder 1" }, + { id: "2345", name: "Folder 2" }, + ]); + + service.folders$.subscribe((folders) => { + expect(folders.every(({ icon }) => icon === "bwi-folder")).toBeTruthy(); + done(); + }); + }); + + it("returns folders that have ciphers within the selected organization", (done) => { + service.folders$.pipe(skipWhile((folders) => folders.length === 2)).subscribe((folders) => { + expect(folders.map((f) => f.label)).toEqual(["Folder 1"]); + done(); + }); + + service.filterForm.patchValue({ + organization: { id: "1234" } as Organization, + }); + + folderViews$.next([ + { id: "1234", name: "Folder 1" }, + { id: "2345", name: "Folder 2" }, + ]); + + cipherViews$.next({ + "1": { folderId: "1234", organizationId: "1234" }, + "2": { folderId: "2345", organizationId: "56789" }, + }); + }); + }); + + describe("filterFunction$", () => { + const ciphers = [ + { type: CipherType.Login, collectionIds: [], organizationId: null }, + { type: CipherType.Card, collectionIds: ["1234"], organizationId: "8978" }, + { type: CipherType.Identity, collectionIds: [], folderId: "5432", organizationId: null }, + { type: CipherType.SecureNote, collectionIds: [], organizationId: null }, + ] as CipherView[]; + + it("filters by cipherType", (done) => { + service.filterFunction$.subscribe((filterFunction) => { + expect(filterFunction(ciphers)).toEqual([ciphers[0]]); + done(); + }); + + service.filterForm.patchValue({ cipherType: CipherType.Login }); + }); + + it("filters by collection", (done) => { + const collection = { id: "1234" } as Collection; + + service.filterFunction$.subscribe((filterFunction) => { + expect(filterFunction(ciphers)).toEqual([ciphers[1]]); + done(); + }); + + service.filterForm.patchValue({ collection }); + }); + + it("filters by folder", (done) => { + const folder = { id: "5432" } as FolderView; + + service.filterFunction$.subscribe((filterFunction) => { + expect(filterFunction(ciphers)).toEqual([ciphers[2]]); + done(); + }); + + service.filterForm.patchValue({ folder }); + }); + + describe("organizationId", () => { + it("filters out ciphers that belong to an organization when MyVault is selected", (done) => { + const organization = { id: MY_VAULT_ID } as Organization; + + service.filterFunction$.subscribe((filterFunction) => { + expect(filterFunction(ciphers)).toEqual([ciphers[0], ciphers[2], ciphers[3]]); + done(); + }); + + service.filterForm.patchValue({ organization }); + }); + + it("filters out ciphers that do not belong to the selected organization", (done) => { + const organization = { id: "8978" } as Organization; + + service.filterFunction$.subscribe((filterFunction) => { + expect(filterFunction(ciphers)).toEqual([ciphers[1]]); + done(); + }); + + service.filterForm.patchValue({ organization }); + }); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts new file mode 100644 index 000000000000..69a7039e2acc --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -0,0 +1,376 @@ +import { Injectable } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder } from "@angular/forms"; +import { + Observable, + combineLatest, + distinctUntilChanged, + map, + startWith, + switchMap, + tap, +} from "rxjs"; + +import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { Collection } from "@bitwarden/common/vault/models/domain/collection"; +import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; +import { ChipSelectOption } from "@bitwarden/components"; + +/** All available cipher filters */ +export type PopupListFilter = { + organization: Organization | null; + collection: Collection | null; + folder: FolderView | null; + cipherType: CipherType | null; +}; + +/** Delimiter that denotes a level of nesting */ +const NESTING_DELIMITER = "/"; + +/** Id assigned to the "My vault" organization */ +export const MY_VAULT_ID = "MyVault"; + +const INITIAL_FILTERS: PopupListFilter = { + organization: null, + collection: null, + folder: null, + cipherType: null, +}; + +@Injectable({ + providedIn: "root", +}) +export class VaultPopupListFiltersService { + /** + * UI form for all filters + */ + filterForm = this.formBuilder.group(INITIAL_FILTERS); + + /** + * Observable for `filterForm` value + */ + filters$ = this.filterForm.valueChanges.pipe( + startWith(INITIAL_FILTERS), + ) as Observable; + + /** + * Static list of ciphers views used in synchronous context + */ + private cipherViews: CipherView[] = []; + + /** + * Observable of cipher views + */ + private cipherViews$: Observable = this.cipherService.cipherViews$.pipe( + tap((cipherViews) => { + this.cipherViews = Object.values(cipherViews); + }), + map((ciphers) => Object.values(ciphers)), + ); + + constructor( + private folderService: FolderService, + private cipherService: CipherService, + private organizationService: OrganizationService, + private i18nService: I18nService, + private collectionService: CollectionService, + private formBuilder: FormBuilder, + ) { + this.filterForm.controls.organization.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(this.validateOrganizationChange.bind(this)); + } + + /** + * Observable whose value is a function that filters an array of `CipherView` objects based on the current filters + */ + filterFunction$: Observable<(ciphers: CipherView[]) => CipherView[]> = this.filters$.pipe( + map( + (filters) => (ciphers: CipherView[]) => + ciphers.filter((cipher) => { + if (filters.cipherType !== null && cipher.type !== filters.cipherType) { + return false; + } + + if ( + filters.collection !== null && + !cipher.collectionIds.includes(filters.collection.id) + ) { + return false; + } + + if (filters.folder !== null && cipher.folderId !== filters.folder.id) { + return false; + } + + const isMyVault = filters.organization?.id === MY_VAULT_ID; + + if (isMyVault) { + if (cipher.organizationId !== null) { + return false; + } + } else if (filters.organization !== null) { + if (cipher.organizationId !== filters.organization.id) { + return false; + } + } + + return true; + }), + ), + ); + + /** + * All available cipher types + */ + readonly cipherTypes: ChipSelectOption[] = [ + { + value: CipherType.Login, + label: this.i18nService.t("typeLogin"), + icon: "bwi-globe", + }, + { + value: CipherType.Card, + label: this.i18nService.t("typeCard"), + icon: "bwi-credit-card", + }, + { + value: CipherType.Identity, + label: this.i18nService.t("typeIdentity"), + icon: "bwi-id-card", + }, + { + value: CipherType.SecureNote, + label: this.i18nService.t("note"), + icon: "bwi-sticky-note", + }, + ]; + + /** Resets `filterForm` to the original state */ + resetFilterForm(): void { + this.filterForm.reset(INITIAL_FILTERS); + } + + /** + * Organization array structured to be directly passed to `ChipSelectComponent` + */ + organizations$: Observable[]> = + this.organizationService.memberOrganizations$.pipe( + map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name"))), + map((orgs) => { + if (!orgs.length) { + return []; + } + + return [ + // When the user is a member of an organization, make the "My Vault" option available + { + value: { id: MY_VAULT_ID } as Organization, + label: this.i18nService.t("myVault"), + icon: "bwi-user", + }, + ...orgs.map((org) => { + let icon = "bwi-business"; + + if (!org.enabled) { + // Show a warning icon if the organization is deactivated + icon = "bwi-exclamation-triangle tw-text-danger"; + } else if (org.planProductType === ProductType.Families) { + // Show a family icon if the organization is a family org + icon = "bwi-family"; + } + + return { + value: org, + label: org.name, + icon, + }; + }), + ]; + }), + ); + + /** + * Folder array structured to be directly passed to `ChipSelectComponent` + */ + folders$: Observable[]> = combineLatest([ + this.filters$.pipe( + distinctUntilChanged( + (previousFilter, currentFilter) => + // Only update the collections when the organizationId filter changes + previousFilter.organization?.id === currentFilter.organization?.id, + ), + ), + this.folderService.folderViews$, + this.cipherViews$, + ]).pipe( + map(([filters, folders, cipherViews]): [PopupListFilter, FolderView[], CipherView[]] => { + if (folders.length === 1 && folders[0].id === null) { + // Do not display folder selections when only the "no folder" option is available. + return [filters, [], cipherViews]; + } + + // Sort folders by alphabetic name + folders.sort(Utils.getSortFunction(this.i18nService, "name")); + let arrangedFolders = folders; + + const noFolder = folders.find((f) => f.id === null); + + if (noFolder) { + // Update `name` of the "no folder" option to "Items with no folder" + noFolder.name = this.i18nService.t("itemsWithNoFolder"); + + // Move the "no folder" option to the end of the list + arrangedFolders = [...folders.filter((f) => f.id !== null), noFolder]; + } + return [filters, arrangedFolders, cipherViews]; + }), + map(([filters, folders, cipherViews]) => { + const organizationId = filters.organization?.id ?? null; + + // When no org or "My vault" is selected, return all folders + if (organizationId === null || organizationId === MY_VAULT_ID) { + return folders; + } + + const orgCiphers = cipherViews.filter((c) => c.organizationId === organizationId); + + // Return only the folders that have ciphers within the filtered organization + return folders.filter((f) => orgCiphers.some((oc) => oc.folderId === f.id)); + }), + map((folders) => { + const nestedFolders = this.getAllFoldersNested(folders); + return new DynamicTreeNode({ + fullList: folders, + nestedList: nestedFolders, + }); + }), + map((folders) => + folders.nestedList.map((f) => this.convertToChipSelectOption(f, "bwi-folder")), + ), + ); + + /** + * Collection array structured to be directly passed to `ChipSelectComponent` + */ + collections$: Observable[]> = combineLatest([ + this.filters$.pipe( + distinctUntilChanged( + (previousFilter, currentFilter) => + // Only update the collections when the organizationId filter changes + previousFilter.organization?.id === currentFilter.organization?.id, + ), + ), + this.collectionService.decryptedCollections$, + ]).pipe( + map(([filters, allCollections]) => { + const organizationId = filters.organization?.id ?? null; + // When the organization filter is selected, filter out collections that do not belong to the selected organization + const collections = + organizationId === null + ? allCollections + : allCollections.filter((c) => c.organizationId === organizationId); + + return collections; + }), + switchMap(async (collections) => { + const nestedCollections = await this.collectionService.getAllNested(collections); + + return new DynamicTreeNode({ + fullList: collections, + nestedList: nestedCollections, + }); + }), + map((collections) => + collections.nestedList.map((c) => this.convertToChipSelectOption(c, "bwi-collection")), + ), + ); + + /** + * Converts the given item into the `ChipSelectOption` structure + */ + private convertToChipSelectOption( + item: TreeNode, + icon: string, + ): ChipSelectOption { + return { + value: item.node, + label: item.node.name, + icon, + children: item.children + ? item.children.map((i) => this.convertToChipSelectOption(i, icon)) + : undefined, + }; + } + + /** + * Returns a nested folder structure based on the input FolderView array + */ + private getAllFoldersNested(folders: FolderView[]): TreeNode[] { + const nodes: TreeNode[] = []; + + folders.forEach((f) => { + const folderCopy = new FolderView(); + folderCopy.id = f.id; + folderCopy.revisionDate = f.revisionDate; + + // Remove "/" from beginning and end of the folder name + // then split the folder name by the delimiter + const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NESTING_DELIMITER) : []; + ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NESTING_DELIMITER); + }); + + return nodes; + } + + /** + * Validate collection & folder filters when the organization filter changes + */ + private validateOrganizationChange(organization: Organization | null): void { + if (!organization) { + return; + } + + const currentFilters = this.filterForm.getRawValue(); + + // When the organization filter changes and a collection is already selected, + // reset the collection filter if the collection does not belong to the new organization filter + if (currentFilters.collection && currentFilters.collection.organizationId !== organization.id) { + this.filterForm.get("collection").setValue(null); + } + + // When the organization filter changes and a folder is already selected, + // reset the folder filter if the folder does not belong to the new organization filter + if ( + currentFilters.folder && + currentFilters.folder.id !== null && + organization.id !== MY_VAULT_ID + ) { + // Get all ciphers that belong to the new organization + const orgCiphers = this.cipherViews.filter((c) => c.organizationId === organization.id); + + // Find any ciphers within the organization that belong to the current folder + const newOrgContainsFolder = orgCiphers.some( + (oc) => oc.folderId === currentFilters.folder.id, + ); + + // If the new organization does not contain the current folder, reset the folder filter + if (!newOrgContainsFolder) { + this.filterForm.get("folder").setValue(null); + } + } + } +} diff --git a/apps/browser/src/vault/popup/settings/sync.component.ts b/apps/browser/src/vault/popup/settings/sync.component.ts index 3fe4de9eb51a..16f388804bb4 100644 --- a/apps/browser/src/vault/popup/settings/sync.component.ts +++ b/apps/browser/src/vault/popup/settings/sync.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { SyncService } from "@bitwarden/common/platform/sync"; @Component({ selector: "app-sync", diff --git a/apps/browser/src/vault/popup/views/popup-cipher.view.ts b/apps/browser/src/vault/popup/views/popup-cipher.view.ts new file mode 100644 index 000000000000..4707eb9eb0f6 --- /dev/null +++ b/apps/browser/src/vault/popup/views/popup-cipher.view.ts @@ -0,0 +1,41 @@ +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductType } from "@bitwarden/common/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; + +/** + * Extended cipher view for the popup. Includes the associated collections and organization + * if applicable. + */ +export class PopupCipherView extends CipherView { + collections?: CollectionView[]; + organization?: Organization; + + constructor( + cipher: CipherView, + collections: CollectionView[] = null, + organization: Organization = null, + ) { + super(); + Object.assign(this, cipher); + this.collections = collections; + this.organization = organization; + } + + /** + * Get the bwi icon for the cipher according to the organization type. + */ + get orgIcon(): "bwi-family" | "bwi-business" | null { + switch (this.organization?.planProductType) { + case ProductType.Free: + case ProductType.Families: + return "bwi-family"; + case ProductType.Teams: + case ProductType.Enterprise: + case ProductType.TeamsStarter: + return "bwi-business"; + default: + return null; + } + } +} diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts index 4800b4c17f3e..5435c6fd7fed 100644 --- a/apps/browser/test.setup.ts +++ b/apps/browser/test.setup.ts @@ -1,3 +1,5 @@ +import "jest-preset-angular/setup-jest"; + // Add chrome storage api const QUOTA_BYTES = 10; const storage = { diff --git a/apps/browser/tsconfig.json b/apps/browser/tsconfig.json index eb2c02fd3fd1..39f9c8211c7b 100644 --- a/apps/browser/tsconfig.json +++ b/apps/browser/tsconfig.json @@ -19,6 +19,9 @@ "@bitwarden/billing": ["../../libs/billing/src"], "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/components": ["../../libs/components/src"], + "@bitwarden/generator-components": ["../../libs/tools/generator/components/src"], + "@bitwarden/generator-core": ["../../libs/tools/generator/core/src"], + "@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"], "@bitwarden/vault-export-core": [ "../../libs/tools/export/vault-export/vault-export-core/src" ], diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 2756ab4395fe..a0b86f06d5de 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -66,8 +66,7 @@ const moduleRules = [ { loader: "babel-loader", options: { - configFile: false, - plugins: ["@angular/compiler-cli/linker/babel"], + configFile: "../../babel.config.json", }, }, ], diff --git a/apps/cli/package.json b/apps/cli/package.json index d8ddde3d670c..1ad09cc17a54 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/cli", "description": "A secure and free password manager for all of your devices.", - "version": "2024.5.0", + "version": "2024.6.0", "keywords": [ "bitwarden", "password", diff --git a/apps/cli/src/platform/services/node-api.service.ts b/apps/cli/src/platform/services/node-api.service.ts index 4849aef1512e..c480d9d1aff7 100644 --- a/apps/cli/src/platform/services/node-api.service.ts +++ b/apps/cli/src/platform/services/node-api.service.ts @@ -6,6 +6,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ApiService } from "@bitwarden/common/services/api.service"; @@ -21,8 +22,10 @@ export class NodeApiService extends ApiService { platformUtilsService: PlatformUtilsService, environmentService: EnvironmentService, appIdService: AppIdService, + refreshAccessTokenErrorCallback: () => Promise, + logService: LogService, + logoutCallback: () => Promise, vaultTimeoutSettingsService: VaultTimeoutSettingsService, - logoutCallback: (expired: boolean) => Promise, customUserAgent: string = null, ) { super( @@ -30,8 +33,10 @@ export class NodeApiService extends ApiService { platformUtilsService, environmentService, appIdService, - vaultTimeoutSettingsService, + refreshAccessTokenErrorCallback, + logService, logoutCallback, + vaultTimeoutSettingsService, customUserAgent, ); } diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index e0311beb247f..597b388a05b1 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -83,6 +83,11 @@ export class Program extends BaseProgram { }); program.on("--help", () => { + writeLn( + chalk.yellowBright( + "\n Tip: Managing and retrieving secrets for dev environments is easier with Bitwarden Secrets Manager. Learn more under https://bitwarden.com/products/secrets-manager/", + ), + ); writeLn("\n Examples:"); writeLn(""); writeLn(" bw login"); diff --git a/apps/cli/src/service-container.ts b/apps/cli/src/service-container.ts index 882791ef9c9a..ff4eb52b84e5 100644 --- a/apps/cli/src/service-container.ts +++ b/apps/cli/src/service-container.ts @@ -97,6 +97,9 @@ import { DefaultStateProvider } from "@bitwarden/common/platform/state/implement import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service"; import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service"; /* eslint-enable import/no-restricted-paths */ +import { SyncService } from "@bitwarden/common/platform/sync"; +// eslint-disable-next-line no-restricted-imports -- Needed for service construction +import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; @@ -120,8 +123,6 @@ import { CollectionService } from "@bitwarden/common/vault/services/collection.s import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; -import { SyncNotifierService } from "@bitwarden/common/vault/services/sync/sync-notifier.service"; -import { SyncService } from "@bitwarden/common/vault/services/sync/sync.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { ImportApiService, @@ -216,7 +217,6 @@ export class ServiceContainer { folderApiService: FolderApiService; userVerificationApiService: UserVerificationApiService; organizationApiService: OrganizationApiServiceAbstraction; - syncNotifierService: SyncNotifierService; sendApiService: SendApiService; devicesApiService: DevicesApiServiceAbstraction; deviceTrustService: DeviceTrustServiceAbstraction; @@ -255,6 +255,8 @@ export class ServiceContainer { p = path.join(process.env.HOME, ".config/Bitwarden CLI"); } + const logoutCallback = async () => await this.logout(); + this.platformUtilsService = new CliPlatformUtilsService(ClientType.Cli, packageJson); this.logService = new ConsoleLogService( this.platformUtilsService.isDev(), @@ -337,6 +339,7 @@ export class ServiceContainer { this.keyGenerationService, this.encryptService, this.logService, + logoutCallback, ); const migrationRunner = new MigrationRunner( @@ -421,18 +424,22 @@ export class ServiceContainer { VaultTimeoutStringType.Never, // default vault timeout ); + const refreshAccessTokenErrorCallback = () => { + throw new Error("Refresh Access token error"); + }; + this.apiService = new NodeApiService( this.tokenService, this.platformUtilsService, this.environmentService, this.appIdService, + refreshAccessTokenErrorCallback, + this.logService, + logoutCallback, this.vaultTimeoutSettingsService, - async (expired: boolean) => await this.logout(), customUserAgent, ); - this.syncNotifierService = new SyncNotifierService(); - this.organizationApiService = new OrganizationApiService(this.apiService, this.syncService); this.containerService = new ContainerService(this.cryptoService, this.encryptService); @@ -485,7 +492,7 @@ export class ServiceContainer { this.logService, this.organizationService, this.keyGenerationService, - async (expired: boolean) => await this.logout(), + logoutCallback, this.stateProvider, ); @@ -639,7 +646,7 @@ export class ServiceContainer { this.avatarService = new AvatarService(this.apiService, this.stateProvider); - this.syncService = new SyncService( + this.syncService = new DefaultSyncService( this.masterPasswordService, this.accountService, this.apiService, @@ -660,7 +667,7 @@ export class ServiceContainer { this.sendApiService, this.userDecryptionOptionsService, this.avatarService, - async (expired: boolean) => await this.logout(), + logoutCallback, this.billingAccountProfileStateService, this.tokenService, this.authService, diff --git a/apps/cli/src/vault/sync.command.ts b/apps/cli/src/vault/sync.command.ts index 073b9b5df48a..c3c6f6375384 100644 --- a/apps/cli/src/vault/sync.command.ts +++ b/apps/cli/src/vault/sync.command.ts @@ -1,4 +1,4 @@ -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { Response } from "../models/response"; import { MessageResponse } from "../models/response/message.response"; diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index c637ea044463..9353b6d44f82 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.80" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "arboard" @@ -169,7 +169,7 @@ checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" name = "base64" version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64urlsafedata" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index d6bf1ac58c76..7b960757cffb 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -14,9 +14,9 @@ manual_test = [] [dependencies] aes = "=0.8.4" -anyhow = "=1.0.80" +anyhow = "=1.0.86" arboard = { version = "=3.3.2", default-features = false, features = ["wayland-data-control"] } -base64 = "=0.22.0" +base64 = "=0.22.1" cbc = { version = "=0.1.2", features = ["alloc"] } futures = "0.3.30" napi = { version = "=2.16.0", features = ["async", "tokio_rt"] } diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index ef0927296a0e..39c62998a675 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -127,7 +127,7 @@ "entitlementsLoginHelper": "resources/entitlements.mas.loginhelper.plist", "hardenedRuntime": false, "extendInfo": { - "LSMinimumSystemVersion": "10.15.0", + "LSMinimumSystemVersion": "12", "ElectronTeamID": "LTZ2PFU5D6" } }, diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 747d8ec98118..ac12731398b6 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -18,8 +18,7 @@ "yargs": "17.7.2" }, "devDependencies": { - "@tsconfig/node16": "1.0.4", - "@types/node": "18.19.29", + "@types/node": "20.14.1", "@types/node-ipc": "9.2.3", "typescript": "4.7.4" } @@ -99,9 +98,10 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==" }, "node_modules/@types/node": { - "version": "18.19.29", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.29.tgz", - "integrity": "sha512-5pAX7ggTmWZdhUrhRWLPf+5oM7F80bcKVCBbr0zwEkTNzTJL2CWQjznpFgHYy6GrzkYi2Yjy7DHKoynFxqPV8g==", + "version": "20.14.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.1.tgz", + "integrity": "sha512-T2MzSGEu+ysB/FkWfqmhV3PLyQlowdptmmgD20C6QxsS8Fmv5SjpZ1ayXaEC0S21/h5UJ9iA6W/5vSNU5l00OA==", + "license": "MIT", "dependencies": { "undici-types": "~5.26.4" } diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 72b2587a4ae0..0f92d5b0b3de 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -23,8 +23,7 @@ "yargs": "17.7.2" }, "devDependencies": { - "@tsconfig/node16": "1.0.4", - "@types/node": "18.19.29", + "@types/node": "20.14.1", "@types/node-ipc": "9.2.3", "typescript": "4.7.4" }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 90d9841a6183..129e9c43f09b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2024.5.0", + "version": "2024.6.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 7feea649c306..e4fdd17dc158 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -1,3 +1,4 @@ +import { DialogRef } from "@angular/cdk/dialog"; import { Component, NgZone, @@ -13,6 +14,7 @@ import { filter, firstValueFrom, map, Subject, takeUntil, timeout } from "rxjs"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; +import { LogoutReason } from "@bitwarden/auth/common"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -40,15 +42,15 @@ import { SystemService } from "@bitwarden/common/platform/abstractions/system.se import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize"; import { StateEventRunnerService } from "@bitwarden/common/platform/state"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UserId } from "@bitwarden/common/types/guid"; import { VaultTimeout, VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { DialogService, ToastOptions, ToastService } from "@bitwarden/components"; import { DeleteAccountComponent } from "../auth/delete-account.component"; import { LoginApprovalComponent } from "../auth/login/login-approval.component"; @@ -108,6 +110,7 @@ export class AppComponent implements OnInit, OnDestroy { private idleTimer: number = null; private isIdle = false; private activeUserId: UserId = null; + private activeSimpleDialog: DialogRef = null; private destroy$ = new Subject(); @@ -207,7 +210,7 @@ export class AppComponent implements OnInit, OnDestroy { break; case "logout": this.loading = message.userId == null || message.userId === this.activeUserId; - await this.logOut(!!message.expired, message.userId); + await this.logOut(message.logoutReason, message.userId); this.loading = false; break; case "lockVault": @@ -545,9 +548,73 @@ export class AppComponent implements OnInit, OnDestroy { this.messagingService.send("updateAppMenu", { updateRequest: updateRequest }); } + private async displayLogoutReason(logoutReason: LogoutReason) { + let toastOptions: ToastOptions; + + switch (logoutReason) { + case "invalidSecurityStamp": + case "sessionExpired": { + toastOptions = { + variant: "warning", + title: this.i18nService.t("loggedOut"), + message: this.i18nService.t("loginExpired"), + }; + break; + } + // We don't expect these scenarios to be common, but we want the user to + // understand why they are being logged out before a process reload. + case "accessTokenUnableToBeDecrypted": { + // Don't create multiple dialogs if this fires multiple times + if (this.activeSimpleDialog) { + // Let the caller of this function listen for the dialog to close + return firstValueFrom(this.activeSimpleDialog.closed); + } + + this.activeSimpleDialog = this.dialogService.openSimpleDialogRef({ + title: { key: "loggedOut" }, + content: { key: "accessTokenUnableToBeDecrypted" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "danger", + }); + + await firstValueFrom(this.activeSimpleDialog.closed); + this.activeSimpleDialog = null; + + break; + } + case "refreshTokenSecureStorageRetrievalFailure": { + // Don't create multiple dialogs if this fires multiple times + if (this.activeSimpleDialog) { + // Let the caller of this function listen for the dialog to close + return firstValueFrom(this.activeSimpleDialog.closed); + } + + this.activeSimpleDialog = this.dialogService.openSimpleDialogRef({ + title: { key: "loggedOut" }, + content: { key: "refreshTokenSecureStorageRetrievalFailure" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "danger", + }); + + await firstValueFrom(this.activeSimpleDialog.closed); + this.activeSimpleDialog = null; + + break; + } + } + + if (toastOptions) { + this.toastService.showToast(toastOptions); + } + } + // Even though the userId parameter is no longer optional doesn't mean a message couldn't be // passing null-ish values to us. - private async logOut(expired: boolean, userId: UserId) { + private async logOut(logoutReason: LogoutReason, userId: UserId) { + await this.displayLogoutReason(logoutReason); + const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); @@ -620,15 +687,7 @@ export class AppComponent implements OnInit, OnDestroy { // This must come last otherwise the logout will prematurely trigger // a process reload before all the state service user data can be cleaned up if (userBeingLoggedOut === activeUserId) { - this.authService.logOut(async () => { - if (expired) { - this.platformUtilsService.showToast( - "warning", - this.i18nService.t("loggedOut"), - this.i18nService.t("loginExpired"), - ); - } - }); + this.authService.logOut(async () => {}); } } @@ -710,7 +769,7 @@ export class AppComponent implements OnInit, OnDestroy { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises options[1] === "logOut" - ? this.logOut(false, userId as UserId) + ? this.logOut("vaultTimeout", userId as UserId) : await this.vaultTimeoutService.lock(userId); } } diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 0452e9be837e..8793587300fe 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -15,10 +15,10 @@ import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwar import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service"; +import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/platform/sync"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; import { UserId } from "@bitwarden/common/types/guid"; -import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { I18nRendererService } from "../../platform/services/i18n.renderer.service"; import { NativeMessagingService } from "../../services/native-messaging.service"; diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index 28efbc7de4ef..27d0977a2e10 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -1299,12 +1299,42 @@ "message": "Wagwoord bygewerk", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Stuur Kluis Uit" }, "fileFormat": { "message": "Lêerformaat" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha-bronadres", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Vergrendel" }, diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index 25a91b95b6c8..a800840dd4a2 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -1299,12 +1299,42 @@ "message": "تم تحديث كلمة المرور", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "تصدير الخزنة" }, "fileFormat": { "message": "صيغة الملف" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "رابط hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "مقفل" }, diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index c26e49d743c8..0143e6c2745f 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -1299,12 +1299,42 @@ "message": "Parol güncəlləndi", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Buradan xaricə köçür" + }, "exportVault": { "message": "Anbarı xaricə köçür" }, "fileFormat": { "message": "Fayl formatı" }, + "fileEncryptedExportWarningDesc": { + "message": "Bu faylın xaricə köçürülməsi, parolla qorunacaq və şifrəsini açmaq üçün fayl parolu tələb olunacaq." + }, + "filePassword": { + "message": "Fayl parolu" + }, + "exportPasswordDescription": { + "message": "Bu parol, bu faylı daxilə və xaricə köçürmək üçün istifadə olunacaq" + }, + "accountRestrictedOptionDescription": { + "message": "Xaricə köçürməni şifrələmək və daxilə köçürməni yalnız mövcud Bitwarden hesabı ilə məhdudlaşdırmaq üçün hesabınızın istifadəçi adı və Ana Parolundan əldə edilən hesab şifrələmə açarınızı istifadə edin." + }, + "passwordProtected": { + "message": "Parolla qorunan" + }, + "passwordProtectedOptionDescription": { + "message": "Xaricə köçürməni şifrələmək üçün bir fayl parolu təyin edin və şifrəni açma parolunu istifadə edərək bunu istənilən Bitwarden hesabına köçürün." + }, + "exportTypeHeading": { + "message": "Xaricə köçürmə növü" + }, + "accountRestricted": { + "message": "Hesab məhdudlaşdırıldı" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Fayl parolu\" və \"Fayl parolunu təsdiqlə\" uyuşmur." + }, "hCaptchaUrl": { "message": "hCaptcha ünvanı", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Təşkilat anbarını xaricə köçürmə" + }, + "exportingOrganizationVaultDesc": { + "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat anbarı ixrac ediləcək. Fərdi anbardakı və digər təşkilat elementlər daxil edilmir.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Kilidli" }, diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index 0371f3bcebbc..4adb3be3e5a4 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -1299,12 +1299,42 @@ "message": "Пароль абноўлены", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Экспартаваць сховішча" }, "fileFormat": { "message": "Фармат файла" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "URL-адрас hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Заблакіравана" }, diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index 071399419c75..7471bebe0299 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -1299,12 +1299,42 @@ "message": "Обновена парола", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Изнасяне от" + }, "exportVault": { "message": "Изнасяне на трезора" }, "fileFormat": { "message": "Формат на файла" }, + "fileEncryptedExportWarningDesc": { + "message": "Изнесеният файл ще бъде защитен с парола, която ще бъде необходима за дешифриране на файла." + }, + "filePassword": { + "message": "Парола на файла" + }, + "exportPasswordDescription": { + "message": "Парола ще се използва при изнасянето и при внасянето на този файл" + }, + "accountRestrictedOptionDescription": { + "message": "Използвайте ключа си за шифриране, който се получава чрез комбиниране на потребителското име на регистрацията Ви и главната парола. С него изнасянето ще се шифрира и внасянето ще бъда възможно само в текущата регистрация в Битуорден." + }, + "passwordProtected": { + "message": "Защита с парола" + }, + "passwordProtectedOptionDescription": { + "message": "Задайте парола за файла, за да шифровате изнесените данни. Ще можете да внесете данните във всяка регистрация в Битуорден използвайки паролата за дешифриране." + }, + "exportTypeHeading": { + "message": "Вид изнасяне" + }, + "accountRestricted": { + "message": "Регистрацията е ограничена" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "Дънните в полетата „Парола на файла“ и „Потвърждаване на паролата на файла“ не съвпадат." + }, "hCaptchaUrl": { "message": "Адрес за hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Изнасяне на трезора на организацията" + }, + "exportingOrganizationVaultDesc": { + "message": "Ще бъдат изнесени само записите от трезора свързан с $ORGANIZATION$. Записите в отделните лични трезори и тези в други организации няма да бъдат включени.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Заключено" }, diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index a6f0e712063f..71749add465f 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -1299,12 +1299,42 @@ "message": "পাসওয়ার্ড হালনাগাদকৃত", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "ভল্ট রফতানি" }, "fileFormat": { "message": "ফাইলের ধরণ" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index 1a489ea397df..a11dbb7e9b9f 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -1299,12 +1299,42 @@ "message": "Lozinka ažurirana", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Izvezi trezor" }, "fileFormat": { "message": "Format datoteke" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index e55128ceeb45..abee9ac2ff09 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -1299,12 +1299,42 @@ "message": "Contrasenya actualitzada", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Exporta caixa forta" }, "fileFormat": { "message": "Format de fitxer" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "Url hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Bloquejat" }, diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index b5f2a4f5f0cb..bcb08de2bea9 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -1299,12 +1299,42 @@ "message": "Heslo bylo aktualizováno", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Exportovat z" + }, "exportVault": { "message": "Exportovat trezor" }, "fileFormat": { "message": "Formát souboru" }, + "fileEncryptedExportWarningDesc": { + "message": "Tento soubor exportu bude chráněn heslem a k dešifrování bude vyžadovat heslo souboru." + }, + "filePassword": { + "message": "Heslo souboru" + }, + "exportPasswordDescription": { + "message": "Toto heslo bude použito pro export a import tohoto souboru" + }, + "accountRestrictedOptionDescription": { + "message": "Pro zašifrování exportu a omezení importu pouze na aktuální účet Bitwardenu použijte šifrovací klíč Vašeho účtu odvozený z uživatelského jména a hlavního hesla." + }, + "passwordProtected": { + "message": "Chráněno heslem" + }, + "passwordProtectedOptionDescription": { + "message": "Nastavte heslo pro šifrování exportu a importujte ho do libovolného účtu Bitwardenu pomocí hesla pro dešifrování." + }, + "exportTypeHeading": { + "message": "Typ exportu" + }, + "accountRestricted": { + "message": "Účet je omezený" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Heslo souboru\" a \"Potvrzení hesla souboru\" se neshodují." + }, "hCaptchaUrl": { "message": "URL hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exportování trezoru organizace" + }, + "exportingOrganizationVaultDesc": { + "message": "Exportován bude jen trezor organizace přidružený k položce $ORGANIZATION$. Osobní položky trezoru a položky z jiných organizací nebudou zahrnuty.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Uzamčeno" }, @@ -2379,7 +2421,7 @@ "message": "Tento požadavek již není platný." }, "approveLoginRequestDesc": { - "message": "Použijte toto zařízení pro schvalování žádostí o přihlášení z jiných zařízení." + "message": "Použije toto zařízení pro schvalování žádostí o přihlášení z jiných zařízení." }, "confirmLoginAtemptForMail": { "message": "Potvrďte pokus o přihlášení z $EMAIL$", diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index aeb76cba5522..5085234b826d 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index 8965c8e2a120..e851f1df4511 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -1299,12 +1299,42 @@ "message": "Adgangskode opdateret", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Eksportér fra" + }, "exportVault": { "message": "Eksportér boks" }, "fileFormat": { "message": "Filformat" }, + "fileEncryptedExportWarningDesc": { + "message": "Denne fileksport vil være adgangskodebeskyttet og kræve filadgangskoden at dekryptere." + }, + "filePassword": { + "message": "Filadgangskode" + }, + "exportPasswordDescription": { + "message": "Denne adgangskode vil blive brugt ved eksport og import af denne fil" + }, + "accountRestrictedOptionDescription": { + "message": "Brug kontokrypteringsnøglen, dannet af kontobrugernavn og Hovedadgangskode, for at kryptere eksporten og hindre import til andre end den aktuelle Bitwarden-konto." + }, + "passwordProtected": { + "message": "Adgangskodebeskyttet" + }, + "passwordProtectedOptionDescription": { + "message": "Opsæt en adgangskode til både at kryptere eksporten samt dekryptere denne ved import til enhver Bitwarden-konto." + }, + "exportTypeHeading": { + "message": "Eksporttype" + }, + "accountRestricted": { + "message": "Konto begrænset" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“Filadgangskode” og “Bekræft filadgangskode“ matcher ikke." + }, "hCaptchaUrl": { "message": "hCaptcha-URL", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Eksport af organisationsboks" + }, + "exportingOrganizationVaultDesc": { + "message": "Kun organisationsboksen tilknyttet $ORGANIZATION$ eksporteres. Emner i individuelle bokse eller andre organisationer medtages ikke.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Låst" }, diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index d544b10bb64a..e5abc4437239 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -1299,12 +1299,42 @@ "message": "Passwort aktualisiert", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export aus" + }, "exportVault": { "message": "Tresor exportieren" }, "fileFormat": { "message": "Dateiformat" }, + "fileEncryptedExportWarningDesc": { + "message": "Dieser Datei-Export ist passwortgeschützt und erfordert das Dateipasswort zum Entschlüsseln." + }, + "filePassword": { + "message": "Dateipasswort" + }, + "exportPasswordDescription": { + "message": "Dieses Passwort wird zum Exportieren und Importieren dieser Datei verwendet" + }, + "accountRestrictedOptionDescription": { + "message": "Verwende den Verschlüsselungsschlüssel deines Kontos, abgeleitet vom Benutzernamen und Master-Passwort, um den Export zu verschlüsseln und den Import auf das aktuelle Bitwarden-Konto zu beschränken." + }, + "passwordProtected": { + "message": "Passwortgeschützt" + }, + "passwordProtectedOptionDescription": { + "message": "Lege ein Dateipasswort fest, um den Export zu verschlüsseln und importiere ihn in ein beliebiges Bitwarden-Konto, wobei das Passwort zum Entschlüsseln genutzt wird." + }, + "exportTypeHeading": { + "message": "Exporttyp" + }, + "accountRestricted": { + "message": "Konto eingeschränkt" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "„Dateipasswort“ und „Dateipasswort bestätigen“ stimmen nicht überein." + }, "hCaptchaUrl": { "message": "hCaptcha URL", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Tresor der Organisation wird exportiert" + }, + "exportingOrganizationVaultDesc": { + "message": "Nur der mit $ORGANIZATION$ verbundene Organisationstresor wird exportiert. Einträge in persönlichen Tresoren oder anderen Organisationen werden nicht berücksichtigt.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Gesperrt" }, diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index 03c7d2183c04..af932beb008a 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -1299,12 +1299,42 @@ "message": "Ο Κωδικός Ενημερώθηκε", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Εξαγωγή Vault" }, "fileFormat": { "message": "Μορφή Αρχείου" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Κλειδωμένο" }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index d27f2735f4c0..709994fb98b1 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -695,6 +695,15 @@ "selfHostedEnvironmentFooter": { "message": "Specify the base URL of your on-premises hosted Bitwarden installation." }, + "selfHostedBaseUrlHint": { + "message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com" + }, + "selfHostedCustomEnvHeader" :{ + "message": "For advanced configuration, you can specify the base URL of each service independently." + }, + "selfHostedEnvFormInvalid" :{ + "message": "You must add either the base Server URL or at least one custom environment." + }, "customEnvironment": { "message": "Custom environment" }, @@ -743,6 +752,9 @@ "loggedOut": { "message": "Logged out" }, + "loggedOutDesc": { + "message": "You have been logged out of your account." + }, "loginExpired": { "message": "Your login session has expired." }, @@ -1212,6 +1224,12 @@ } } }, + "errorRefreshingAccessToken":{ + "message": "Access Token Refresh Error" + }, + "errorRefreshingAccessTokenDesc":{ + "message": "No refresh token or API keys found. Please try logging out and logging back in." + }, "help": { "message": "Help" }, @@ -2480,6 +2498,12 @@ "important": { "message": "Important:" }, + "accessTokenUnableToBeDecrypted": { + "message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue." + }, + "refreshTokenSecureStorageRetrievalFailure": { + "message": "You have been logged out because your refresh token could not be retrieved. Please log in again to resolve this issue." + }, "masterPasswordHint": { "message": "Your master password cannot be recovered if you forget it!" }, diff --git a/apps/desktop/src/locales/en_GB/messages.json b/apps/desktop/src/locales/en_GB/messages.json index 5e337875324b..45b724e47e42 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha URL", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organisation vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organisations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index efc498921453..da4dca84e4a6 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organisation vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organisations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index 0b6c91a92123..55e9daa72b3b 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index a6a8b2a8027e..130c18b391e4 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -1299,12 +1299,42 @@ "message": "Contraseña actualizada", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Exportar caja fuerte" }, "fileFormat": { "message": "Formato de archivo" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Bloqueado" }, diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index 7217568b5102..d26fc6521319 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -1299,12 +1299,42 @@ "message": "Parool on uuendatud", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Ekspordi hoidla" }, "fileFormat": { "message": "Failivorming" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Lukustatud" }, diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index 0f41c667e91a..82406869f5a8 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -1299,12 +1299,42 @@ "message": "Pasahitza eguneratu da", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Esportatu kutxa gotorra" }, "fileFormat": { "message": "Fitxategiaren formatua" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Blokeatuta" }, diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index f5a5c3575ce5..3c5fb785b7fc 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -1299,12 +1299,42 @@ "message": "کلمه عبور به‌روزرسانی شد", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "برون ریزی گاوصندوق" }, "fileFormat": { "message": "فرمت پرونده" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "نشانی اینترنتی hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "قفل شده" }, diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index 893da1309f3b..084476144337 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -1299,12 +1299,42 @@ "message": "Salasana vaihdettiin", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Vie lähteestä" + }, "exportVault": { "message": "Vie holvi" }, "fileFormat": { "message": "Tiedostomuoto" }, + "fileEncryptedExportWarningDesc": { + "message": "Tämä vientitiedosto suojataan salasanalla, joka on syötettävä ja salauksen purkamiseksi." + }, + "filePassword": { + "message": "Tiedoston salasana" + }, + "exportPasswordDescription": { + "message": "Tätä salasanaa käytetään tämän tiedoston viennissä ja tuonnissa" + }, + "accountRestrictedOptionDescription": { + "message": "Salaa vienti ja rajoita tuonti vain nykyiselle Bitwarden-tilille tilisi käyttäjätunnukseen ja pääsalasanaan pohjautuvalla salausavaimella." + }, + "passwordProtected": { + "message": "Salasanasuojattu" + }, + "passwordProtectedOptionDescription": { + "message": "Salaa vientitiedosto salasanalla, joka mahdollistaa sen tuonnin mille tahansa Bitwarden-tilille." + }, + "exportTypeHeading": { + "message": "Viennin tyyppi" + }, + "accountRestricted": { + "message": "Tiliä on rajoitettu" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Tiedoston salasana\" ja \"Vahvista tiedoston salasana\" eivät täsmää." + }, "hCaptchaUrl": { "message": "hCaptcha-URL", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Organisaation holvin vienti" + }, + "exportingOrganizationVaultDesc": { + "message": "Vain organisaatioon $ORGANIZATION$ liitetyn holvin kohteet viedään. Yksityisen holvin ja muiden organisaatioiden kohteita ei sisällytetä.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Lukittu" }, diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index e45770ab1801..0180d46959ea 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -1299,12 +1299,42 @@ "message": "Na-update ang password", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "I-export vault" }, "fileFormat": { "message": "Format ng file" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Naka-lock" }, diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index 163bc548acf0..ef8f783dd310 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -1299,12 +1299,42 @@ "message": "Mot de passe mis à jour", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Exporter le coffre" }, "fileFormat": { "message": "Format de fichier" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "URL hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Verrouillé" }, diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index c489ab726024..7b9cd9ef0f17 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index 7a28b04d2e07..0f664ed02e7d 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -1299,12 +1299,42 @@ "message": "הסיסמה עודכנה", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "יצוא כספת" }, "fileFormat": { "message": "תבנית קובץ" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "כתובת אתר hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "נָעוּל" }, diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index 3e752df95eb3..765510dc32e8 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index be3c7c28e37a..d1d4a4672b58 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -1299,12 +1299,42 @@ "message": "Lozinka ažurirana", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Izvezi trezor" }, "fileFormat": { "message": "Format datoteke" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Zaključano" }, diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index 927fe8590093..869b92167f61 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -1299,12 +1299,42 @@ "message": "A jelszó frissítésre került.", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Exportálás innen:" + }, "exportVault": { "message": "Széf exportálása" }, "fileFormat": { "message": "Fájlformátum" }, + "fileEncryptedExportWarningDesc": { + "message": "Ez a fájl exportálás jelszóval védett és a visszafejtéshez a fájl jelszó megadása szükséges." + }, + "filePassword": { + "message": "Fájl jelszó" + }, + "exportPasswordDescription": { + "message": "Ezt a jelszó kerül használatba a fájl exportálására és importálására." + }, + "accountRestrictedOptionDescription": { + "message": "Használjuk a fiók felhasználónevéből és mesterjelszavából származó fióktitkosítási kulcsot az exportálás titkosításához és az importálást csak az aktuális Bitwarden fiókra korlátozzuk." + }, + "passwordProtected": { + "message": "Jelszóval védett" + }, + "passwordProtectedOptionDescription": { + "message": "Állítsunk be egy fájl jelszót az exportálás titkosításához és importáljuk azt bármely Bitwarden fiókba a visszafejtéshez használt jelszó használatával." + }, + "exportTypeHeading": { + "message": "Exportálási típus" + }, + "accountRestricted": { + "message": "Korlátozott fiók" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "A “Fájl jelszó” és a “Fájl jelszó megerősítés“ nem egyezik." + }, "hCaptchaUrl": { "message": "hCaptcha webcím", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Szervezeti széf exportálása" + }, + "exportingOrganizationVaultDesc": { + "message": "Csak $ORGANIZATION$ névvel társított szervezeti széf kerül exportálásra. Ebbe nem kerülnek be a személyes és más szervezeti széf elemek.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Lezárva" }, diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index 46735671f04f..cc25a1c73ff2 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -1299,12 +1299,42 @@ "message": "Kata Sandi telah Diperbarui", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Ekspor Brankas" }, "fileFormat": { "message": "File Format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Terkunci" }, diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index 7ab75fd98150..b016dc5e4800 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -1299,12 +1299,42 @@ "message": "Password aggiornata", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Esporta da" + }, "exportVault": { "message": "Esporta cassaforte" }, "fileFormat": { "message": "Formato file" }, + "fileEncryptedExportWarningDesc": { + "message": "Questo file esportato sarà protetto e richiederà la password del file per decifrarlo." + }, + "filePassword": { + "message": "Password del file" + }, + "exportPasswordDescription": { + "message": "La password sarà utilizzata per importare ed esportare questo file" + }, + "accountRestrictedOptionDescription": { + "message": "Usa la chiave di crittografia dell'account, derivata dal nome utente e dalla password principale del tuo account, per crittografare il file di esportazione e limitare l'importazione solo all'account Bitwarden corrente." + }, + "passwordProtected": { + "message": "Protetto da password" + }, + "passwordProtectedOptionDescription": { + "message": "Imposta una password del file per crittografare il file esportato e importarlo in qualsiasi account Bitwarden usando la password per decrittografarlo." + }, + "exportTypeHeading": { + "message": "Tipo di esportazione" + }, + "accountRestricted": { + "message": "Account limitato" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "Le due password del file non corrispondono." + }, "hCaptchaUrl": { "message": "URL hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Esportando cassaforte dell'organizzazione" + }, + "exportingOrganizationVaultDesc": { + "message": "Solo la cassaforte dell'organizzazione associata a $ORGANIZATION$ sarà esportata. Elementi nelle casseforti individuali o in altre organizzazioni non saranno inclusi.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Bloccato" }, diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index e5a3fbcfb939..0be6aea461f2 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -1299,12 +1299,42 @@ "message": "パスワード更新日", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "エクスポート元" + }, "exportVault": { "message": "保管庫のエクスポート" }, "fileFormat": { "message": "ファイル形式" }, + "fileEncryptedExportWarningDesc": { + "message": "エクスポートするファイルはパスワードで保護され、復号するにはファイルパスワードが必要になります。" + }, + "filePassword": { + "message": "ファイルパスワード" + }, + "exportPasswordDescription": { + "message": "このパスワードはこのファイルのエクスポートとインポート時に使用します" + }, + "accountRestrictedOptionDescription": { + "message": "アカウントのユーザー名とマスターパスワードから得られる暗号化キーを使用してエクスポートするデータを暗号化し、現在の Bitwarden アカウントのみがインポートできるよう制限します。" + }, + "passwordProtected": { + "message": "パスワード保護あり" + }, + "passwordProtectedOptionDescription": { + "message": "エクスポートを暗号化するためのファイルパスワードを設定します。そのパスワードを使用して、任意の Bitwarden アカウントにインポートします。" + }, + "exportTypeHeading": { + "message": "エクスポートの種類" + }, + "accountRestricted": { + "message": "アカウント制限" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "「ファイルパスワード」と「ファイルパスワードの確認」が一致しません。" + }, "hCaptchaUrl": { "message": "hCaptcha URL", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "組織保管庫のエクスポート" + }, + "exportingOrganizationVaultDesc": { + "message": "$ORGANIZATION$ に関連付けられた組織保管庫のみがエクスポートされます。個々の保管庫または他の組織にあるアイテムは含まれません。", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "ロック中" }, diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index c489ab726024..7b9cd9ef0f17 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index c489ab726024..7b9cd9ef0f17 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index 8379628c437e..63f2d0326d0c 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -1299,12 +1299,42 @@ "message": "ಪಾಸ್ವರ್ಡ್ ನವೀಕರಿಸಲಾಗಿದೆ", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "ರಫ್ತು ವಾಲ್ಟ್" }, "fileFormat": { "message": "ಕಡತದ ಮಾದರಿ" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index 5b1bcafa425f..1a5e6353520c 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -1299,12 +1299,42 @@ "message": "비밀번호 업데이트됨", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "보관함 내보내기" }, "fileFormat": { "message": "파일 형식" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha URL", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "잠김" }, diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index e24f4b78f8a3..edb15332c220 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -1299,12 +1299,42 @@ "message": "Slaptažodis atnaujintas", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Eksportuoti saugyklą" }, "fileFormat": { "message": "Failo formatas" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha nuoroda", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Užrakinta" }, diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index d93dc9e8599b..4a91e491a546 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -1299,12 +1299,42 @@ "message": "Parole atjaunināta", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Izgūt no" + }, "exportVault": { "message": "Izgūt glabātavas saturu" }, "fileFormat": { "message": "Datnes veids" }, + "fileEncryptedExportWarningDesc": { + "message": "Šī datņu izgūšana būs aizsargāta ar paroli, un būs nepieciešama datnes parole, lai to atšifrētu." + }, + "filePassword": { + "message": "Datnes parole" + }, + "exportPasswordDescription": { + "message": "Šī parole tiks izmantota, lai izgūtu un ievietotu šo datni" + }, + "accountRestrictedOptionDescription": { + "message": "Jāizmanto konta šifrēšanas atslēga, kas iegūta no lietotājvārda un galvenās paroles, lai šifrētu izguvi un atļautu ievietošanu tikai pašreizējā Bitwarden kontā." + }, + "passwordProtected": { + "message": "Aizsargāts ar paroli" + }, + "passwordProtectedOptionDescription": { + "message": "Uzstādīt paroli, lai šifrētu izguvi un tad to ievietotu jebkurā Bitwarden kontā, izmantojot atšifrēšanas paroli." + }, + "exportTypeHeading": { + "message": "Izgūšanas veids" + }, + "accountRestricted": { + "message": "Konts ir ierobežots" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Datnes parole\" un \"Apstiprināt datnes paroli\" vērtības nesakrīt." + }, "hCaptchaUrl": { "message": "hCaptcha URL", "description": "hCaptcha is the name of a website, should not be translated" @@ -1347,7 +1377,7 @@ "message": "Apstiprināt glabātavas satura izgūšanu" }, "exportWarningDesc": { - "message": "Šī izguve satur glabātavas datus nešifrētā veidā. Izdoto datni nevajadzētu glabāt vai sūtīt nedrošos veidos (piemēram, e-pastā). Izdzēst to uzreiz pēc izmantošanas." + "message": "Šī izguve satur glabātavas datus nešifrētā veidā. Izdoto datni nevajadzētu glabāt vai sūtīt nedrošos veidos (piemēram, e-pastā). Tā ir jāizdzēš uzreiz pēc izmantošanas." }, "encExportKeyWarningDesc": { "message": "Šī izguve šifrē datus ar konta šifrēšanas atslēgu. Ja tā jebkad tiks mainīta, izvadi vajadzētu veikt vēlreiz, jo vairs nebūs iespējams atšifrēt šo datni." @@ -2060,7 +2090,7 @@ "message": "Sesijai iestājās noildze. Lūgums mēģināt pieteikties vēlreiz." }, "exportingPersonalVaultTitle": { - "message": "Izdod personīgo glabātavu" + "message": "Izgūst personīgo glabātavu" }, "exportingIndividualVaultDescription": { "message": "Tiks izgūti tikai atsevišķi glabātavas vienumi, kas ir saistīti ar $EMAIL$. Apvienības glabātavas vienumi netiks iekļauti. Tiks izgūta tikai glabātavas vienumu informācija, un saistītie pielikumi netiks iekļauti.", @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Izgūst apvienības glabātavu" + }, + "exportingOrganizationVaultDesc": { + "message": "Tiks izgūta tikai apvienības glabātava, kas ir saistīta ar $ORGANIZATION$. Atsevišķu glabātavu vai citu apvienību vienumi netiks iekļauti.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Aizslēgta" }, @@ -2685,7 +2727,7 @@ "message": "Kļūda izguves datnes atšifrēšanā. Izmantotā atslēga neatbilst tai, kas tika izmantota satura izgūšanai." }, "invalidFilePassword": { - "message": "Nederīga datnes parole, lūgums izmantot to paroli, kas tika ievadīta izdošanas datnes izveidošanas brīdī." + "message": "Nederīga datnes parole, lūgums izmantot to paroli, kas tika ievadīta izgūšanas datnes izveidošanas brīdī." }, "importDestination": { "message": "Ievietošanas galamērķis" diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index a19623dbf9ef..400fc1bba9e6 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -1299,12 +1299,42 @@ "message": "Lozinka ažurirana", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Izvezi trezor" }, "fileFormat": { "message": "Format datoteke" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index d1a20855478c..fec41cd119c7 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -1299,12 +1299,42 @@ "message": "പാസ്‍വേഡ് പുതുക്കി", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "വാൾട് എക്സ്പോർട്" }, "fileFormat": { "message": "ഫയൽ ഫോർമാറ്റ്" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index c489ab726024..7b9cd9ef0f17 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index 2f19a2f513f3..54f943a72c80 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index 5af3025f0e52..1f6c7fed6468 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -1299,12 +1299,42 @@ "message": "Passordet ble oppdatert den", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Eksporter hvelvet" }, "fileFormat": { "message": "Filformat" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Låst" }, diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index 6f6f7d95989d..a766c4bb6e5c 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index 7d5132c872f0..f3fe4886abc0 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -1299,12 +1299,42 @@ "message": "Wachtwoord bijgewerkt", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Exporteren vanuit" + }, "exportVault": { "message": "Kluis exporteren" }, "fileFormat": { "message": "Bestandsindeling" }, + "fileEncryptedExportWarningDesc": { + "message": "We beveiligen deze bestandsexport met een wachtwoord beveiligd, je hebt het bestandswachtwoord nodig om het te decoderen." + }, + "filePassword": { + "message": "Bestandswachtwoord" + }, + "exportPasswordDescription": { + "message": "We gebruiken dit wachtwoord bij het exporteren en importeren van dit bestand" + }, + "accountRestrictedOptionDescription": { + "message": "Gebruik de encryptiesleutel van je account, afgeleid van je gebruikersnaam en hoodfwachtwoord, om de export te versleutelen en importeren te beperken tot het huidige Bitwarden-account." + }, + "passwordProtected": { + "message": "Beveiligd met wachtwoord" + }, + "passwordProtectedOptionDescription": { + "message": "Stel een bestandswachtwoord in om de export te versleutelen en te importeren naar een willekeurig Bitwarden-account met het wachtwoord voor decoderen." + }, + "exportTypeHeading": { + "message": "Exporttype" + }, + "accountRestricted": { + "message": "Account beperkt" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Bestandswachtwoord\" en \"Bestandswachtwoord bevestigen\" komen niet overeen." + }, "hCaptchaUrl": { "message": "hCaptcha-URL", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Organisatiekluis exporteren" + }, + "exportingOrganizationVaultDesc": { + "message": "Exporteert alleen de organisatiekluis van $ORGANIZATION$. Geen persoonlijke kluis-items of items van andere organisaties.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Vergrendeld" }, diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index 0de15cbe56bd..10016168346c 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "Filformat" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha-nettadresse", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index bcc358730aee..8146636277bb 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index 237d94faa381..01fe7622e718 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -1299,12 +1299,42 @@ "message": "Hasło zostało zaktualizowane", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Eksportuj z" + }, "exportVault": { "message": "Eksportuj sejf" }, "fileFormat": { "message": "Format pliku" }, + "fileEncryptedExportWarningDesc": { + "message": "Plik będzie chroniony hasłem, które będzie wymagane do odszyfrowania pliku." + }, + "filePassword": { + "message": "Hasło do pliku" + }, + "exportPasswordDescription": { + "message": "Hasło będzie używane do eksportowania i importowania pliku" + }, + "accountRestrictedOptionDescription": { + "message": "Użyj klucza szyfrowania konta, pochodzącego z nazwy użytkownika konta i hasła głównego, aby zaszyfrować eksport i ograniczyć import tylko do bieżącego konta Bitwarden." + }, + "passwordProtected": { + "message": "Chroniona hasłem" + }, + "passwordProtectedOptionDescription": { + "message": "Ustaw hasło dla pliku, aby zaszyfrować eksport i zaimportować je na dowolne konto Bitwarden przy użyciu hasła do odszyfrowania." + }, + "exportTypeHeading": { + "message": "Rodzaj eksportu" + }, + "accountRestricted": { + "message": "Konto ograniczone" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“Hasło pliku” i “Potwierdź hasło pliku“ nie pasują do siebie." + }, "hCaptchaUrl": { "message": "Adres URL hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Eksportowanie sejfu organizacji" + }, + "exportingOrganizationVaultDesc": { + "message": "Tylko sejf organizacji powiązany z $ORGANIZATION$ zostanie wyeksportowany. Pozycje w poszczególnych sejfach lub innych organizacji nie będą uwzględnione.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Zablokowany" }, diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index 225bef631263..77ae4a5592b6 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -1299,12 +1299,42 @@ "message": "Senha atualizada", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Exportar cofre" }, "fileFormat": { "message": "Formato do arquivo" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Bloqueado" }, diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index b2ff16748eca..5596e6ee45e6 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -1299,12 +1299,42 @@ "message": "Palavra-passe atualizada a", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Exportar de" + }, "exportVault": { "message": "Exportar cofre" }, "fileFormat": { "message": "Formato do ficheiro" }, + "fileEncryptedExportWarningDesc": { + "message": "A exportação deste ficheiro será protegida por uma palavra-passe e exigirá a palavra-passe do ficheiro para ser desencriptada." + }, + "filePassword": { + "message": "Palavra-passe do ficheiro" + }, + "exportPasswordDescription": { + "message": "Esta palavra-passe será utilizada para exportar e importar este ficheiro" + }, + "accountRestrictedOptionDescription": { + "message": "Utilize a chave de encriptação da sua conta, derivada do nome de utilizador e da palavra-passe mestra da sua conta, para encriptar a exportação e restringir a importação apenas à conta Bitwarden atual." + }, + "passwordProtected": { + "message": "Protegido por palavra-passe" + }, + "passwordProtectedOptionDescription": { + "message": "Defina uma palavra-passe do ficheiro para encriptar a exportação e importe-a para qualquer conta Bitwarden utilizando a palavra-passe de desencriptação." + }, + "exportTypeHeading": { + "message": "Tipo de exportação" + }, + "accountRestricted": { + "message": "Conta restringida" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Palavra-passe do ficheiro\" e \"Confirmar palavra-passe do ficheiro\" não correspondem." + }, "hCaptchaUrl": { "message": "URL do hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "A exportar o cofre da organização" + }, + "exportingOrganizationVaultDesc": { + "message": "Apenas o cofre da organização associado a $ORGANIZATION$ será exportado. Os itens em cofres individuais ou noutras organizações não serão incluídos.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Bloqueado" }, diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index 27c9bcd12291..9ffe8da928cc 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -1299,12 +1299,42 @@ "message": "Parolă actualizată", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export de seif" }, "fileFormat": { "message": "Format de fișier" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "Url-ul hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Blocat" }, diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index e6e621ed55f8..665a18d1cb77 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -1299,12 +1299,42 @@ "message": "Пароль обновлен", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Экспорт из" + }, "exportVault": { "message": "Экспорт хранилища" }, "fileFormat": { "message": "Формат файла" }, + "fileEncryptedExportWarningDesc": { + "message": "Экспорт этого файла будет защищен паролем, и для расшифровки потребуется пароль файла." + }, + "filePassword": { + "message": "Пароль к файлу" + }, + "exportPasswordDescription": { + "message": "Этот пароль будет использоваться для экспорта и импорта этого файла" + }, + "accountRestrictedOptionDescription": { + "message": "Использовать ключ шифрования вашего аккаунта, полученный из имени пользователя и мастер-пароля, для шифрования экспорта и ограничения импорта только для текущего аккаунта Bitwarden." + }, + "passwordProtected": { + "message": "Пароль защищен" + }, + "passwordProtectedOptionDescription": { + "message": "Установите пароль файла для шифрования экспорта и импортируйте его в любую учетную запись Bitwarden, используя пароль для расшифровки." + }, + "exportTypeHeading": { + "message": "Тип экспорта" + }, + "accountRestricted": { + "message": "Ограничено аккаунтом" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Пароль к файлу\" и \"Подтверждение пароля к файлу\" не совпадают." + }, "hCaptchaUrl": { "message": "URL hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Экспорт хранилища организации" + }, + "exportingOrganizationVaultDesc": { + "message": "Будет экспортировано только хранилище организации, связанное с $ORGANIZATION$. Элементы из личных хранилищ и из других организаций включены не будут.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Заблокировано" }, diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index 829febb7a937..b2c744761fe5 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index f6ab99455c44..af48e2ed5290 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -1299,12 +1299,42 @@ "message": "Heslo bolo aktualizované", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Exportovať z" + }, "exportVault": { "message": "Export trezoru" }, "fileFormat": { "message": "Formát Súboru" }, + "fileEncryptedExportWarningDesc": { + "message": "Tento exportovaný súbor bude chránený heslom a na dešifrovanie bude potrebné heslo súboru." + }, + "filePassword": { + "message": "Heslo súboru" + }, + "exportPasswordDescription": { + "message": "Toto heslo sa použije na export a import tohto súboru" + }, + "accountRestrictedOptionDescription": { + "message": "Na zašifrovanie exportu a obmedzenie importu len na aktuálny účet Bitwarden použite šifrovací kľúč účtu odvodený z používateľského mena a hlavného hesla účtu." + }, + "passwordProtected": { + "message": "Chránené heslom" + }, + "passwordProtectedOptionDescription": { + "message": "Nastavte heslo súboru na zašifrovanie exportu a importujte ho do akéhokoľvek účtu Bitwarden pomocou hesla na dešifrovanie." + }, + "exportTypeHeading": { + "message": "Typ exportu" + }, + "accountRestricted": { + "message": "Obmedzený účet" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "\"Heslo súboru\" a \"Potvrdiť heslo súboru\" sa nezhodujú." + }, "hCaptchaUrl": { "message": "URL hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exportovanie trezora organizácie" + }, + "exportingOrganizationVaultDesc": { + "message": "Exportované budú iba položky trezora organizácie spojené s $ORGANIZATION$. Položky osobného trezora a položky z iných organizácií nebudú zahrnuté.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Zamknutý" }, diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index 968f34ad747c..9bdec66f19cf 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -1299,12 +1299,42 @@ "message": "Geslo je bilo posodobljeno", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Izvoz trezorja" }, "fileFormat": { "message": "Format datoteke" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index 7967e25b3f6d..de403629d14d 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -1299,12 +1299,42 @@ "message": "Лозинка ажурирана", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Извоз сефа" }, "fileFormat": { "message": "Формат датотеке" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Закључано" }, diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index 6e731f777c5d..9bae4e883dc9 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -1299,12 +1299,42 @@ "message": "Lösenordet uppdaterades", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Exportera från" + }, "exportVault": { "message": "Exportera valv" }, "fileFormat": { "message": "Filformat" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha-URL", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Låst" }, diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index c489ab726024..7b9cd9ef0f17 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -1299,12 +1299,42 @@ "message": "Password updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export vault" }, "fileFormat": { "message": "File format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index dce9013765fb..cfc701aa5aa3 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -1299,12 +1299,42 @@ "message": "Password Updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export Vault" }, "fileFormat": { "message": "File Format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha Url", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index ad828189877b..224bf36cfd70 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -1299,12 +1299,42 @@ "message": "Parola güncelleme", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Kasayı dışa aktar" }, "fileFormat": { "message": "Dosya biçimi" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha adresi", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Kilitli" }, diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index 6503f3c19ab0..546005db20cb 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -1299,12 +1299,42 @@ "message": "Пароль оновлено", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Експортувати з" + }, "exportVault": { "message": "Експортувати сховище" }, "fileFormat": { "message": "Формат файлу" }, + "fileEncryptedExportWarningDesc": { + "message": "Цей експортований файл буде захищений паролем, який необхідно ввести для його розшифрування." + }, + "filePassword": { + "message": "Пароль файлу" + }, + "exportPasswordDescription": { + "message": "Цей пароль буде використано для експортування та імпортування цього файлу" + }, + "accountRestrictedOptionDescription": { + "message": "Використовуйте ключ шифрування свого облікового запису, створений на основі імені користувача й головного пароля, щоб зашифрувати експортовані дані та обмежити можливість імпортування лише до поточного облікового запису Bitwarden." + }, + "passwordProtected": { + "message": "Захищено паролем" + }, + "passwordProtectedOptionDescription": { + "message": "Встановіть пароль файлу, щоб зашифрувати експортовані дані та імпортувати до будь-якого облікового запису Bitwarden за допомогою цього пароля." + }, + "exportTypeHeading": { + "message": "Тип експорту" + }, + "accountRestricted": { + "message": "Обмежено обліковим записом" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "Пароль файлу та підтвердження пароля відрізняються." + }, "hCaptchaUrl": { "message": "URL-адреса hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Експортування сховища організації" + }, + "exportingOrganizationVaultDesc": { + "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$. Елементи особистих сховищ або інших організацій не будуть включені.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Заблоковано" }, diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index 1a0689b1329f..0fa1d7253af9 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -1299,12 +1299,42 @@ "message": "Password Updated", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "Export Vault" }, "fileFormat": { "message": "File Format" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "Url hCaptcha", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "Đã khóa" }, diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 35cb9ec07ba0..22b96d6e4b66 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -1299,12 +1299,42 @@ "message": "密码更新于", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "导出自" + }, "exportVault": { "message": "导出密码库" }, "fileFormat": { "message": "文件格式" }, + "fileEncryptedExportWarningDesc": { + "message": "此文件导出将受密码保护,需要文件密码才能解密。" + }, + "filePassword": { + "message": "文件密码" + }, + "exportPasswordDescription": { + "message": "此密码将用于导出和导入此文件" + }, + "accountRestrictedOptionDescription": { + "message": "使用衍生自您账户的用户名和主密码的账户加密密钥,以加密此导出并限制只能导入到当前的 Bitwarden 账户。" + }, + "passwordProtected": { + "message": "密码保护" + }, + "passwordProtectedOptionDescription": { + "message": "设置一个文件密码用来加密此导出,并使用此密码解密以导入到任意 Bitwarden 账户。" + }, + "exportTypeHeading": { + "message": "导出类型" + }, + "accountRestricted": { + "message": "账户受限" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "「文件密码」与「确认文件密码」不一致。" + }, "hCaptchaUrl": { "message": "hCaptcha URL", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "正在导出组织密码库" + }, + "exportingOrganizationVaultDesc": { + "message": "仅会导出与 $ORGANIZATION$ 关联的组织密码库数据。不包括个人密码库和其他组织中的项目。", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "已锁定" }, diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index 9ff900a41e22..099865217c03 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -1299,12 +1299,42 @@ "message": "密碼更新於", "description": "ex. Date this password was updated" }, + "exportFrom": { + "message": "Export from" + }, "exportVault": { "message": "匯出密碼庫" }, "fileFormat": { "message": "檔案格式" }, + "fileEncryptedExportWarningDesc": { + "message": "This file export will be password protected and require the file password to decrypt." + }, + "filePassword": { + "message": "File password" + }, + "exportPasswordDescription": { + "message": "This password will be used to export and import this file" + }, + "accountRestrictedOptionDescription": { + "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + }, + "passwordProtected": { + "message": "Password protected" + }, + "passwordProtectedOptionDescription": { + "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + }, + "exportTypeHeading": { + "message": "Export type" + }, + "accountRestricted": { + "message": "Account restricted" + }, + "filePasswordAndConfirmFilePasswordDoNotMatch": { + "message": "“File password” and “Confirm file password“ do not match." + }, "hCaptchaUrl": { "message": "hCaptcha URL", "description": "hCaptcha is the name of a website, should not be translated" @@ -2071,6 +2101,18 @@ } } }, + "exportingOrganizationVaultTitle": { + "message": "Exporting organization vault" + }, + "exportingOrganizationVaultDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "ACME Moving Co." + } + } + }, "locked": { "message": "已鎖定" }, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 16a6cb0601ae..19f65911e87d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3,6 +3,7 @@ import * as path from "path"; import { app } from "electron"; import { Subject, firstValueFrom } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; @@ -31,6 +32,7 @@ import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider"; import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service"; import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service"; +import { UserId } from "@bitwarden/common/types/guid"; /* eslint-enable import/no-restricted-paths */ import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service"; @@ -184,6 +186,7 @@ export class Main { this.keyGenerationService, this.encryptService, this.logService, + async (logoutReason: LogoutReason, userId?: UserId) => {}, ); this.migrationRunner = new MigrationRunner( @@ -209,11 +212,9 @@ export class Main { ); this.desktopSettingsService = new DesktopSettingsService(stateProvider); - const biometricStateService = new DefaultBiometricStateService(stateProvider); this.windowMain = new WindowMain( - this.stateService, biometricStateService, this.logService, this.storageService, diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 64b4bc48d288..e82d16ee9fd4 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -6,7 +6,6 @@ import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "elect import { firstValueFrom } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; @@ -38,7 +37,6 @@ export class WindowMain { readonly defaultHeight = 600; constructor( - private stateService: StateService, private biometricStateService: BiometricStateService, private logService: LogService, private storageService: AbstractStorageService, diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 508c42fa7205..34a4dc99f65d 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2024.5.0", + "version": "2024.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2024.5.0", + "version": "2024.6.0", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-native": "file:../desktop_native", diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index ea4b95491cba..3a629f37cb0e 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2024.5.0", + "version": "2024.6.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/desktop/src/vault/app/vault/vault.component.ts b/apps/desktop/src/vault/app/vault/vault.component.ts index 208bbc70f03a..37992ecea0ed 100644 --- a/apps/desktop/src/vault/app/vault/vault.component.ts +++ b/apps/desktop/src/vault/app/vault/vault.component.ts @@ -23,7 +23,7 @@ import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broa import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index eb054ba80bab..ad8112db4853 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -17,6 +17,9 @@ "@bitwarden/billing": ["../../libs/billing/src"], "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/components": ["../../libs/components/src"], + "@bitwarden/generator-components": ["../../libs/tools/generator/components/src"], + "@bitwarden/generator-core": ["../../libs/tools/generator/core/src"], + "@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"], "@bitwarden/vault-export-core": [ "../../libs/tools/export/vault-export/vault-export-core/src" ], diff --git a/apps/desktop/webpack.renderer.js b/apps/desktop/webpack.renderer.js index 1ebeadef055c..dc3cdf1fef5d 100644 --- a/apps/desktop/webpack.renderer.js +++ b/apps/desktop/webpack.renderer.js @@ -24,8 +24,7 @@ const common = { { loader: "babel-loader", options: { - configFile: false, - plugins: ["@angular/compiler-cli/linker/babel"], + configFile: "../../babel.config.json", }, }, ], diff --git a/apps/web/package.json b/apps/web/package.json index 6e5355c7086b..286811dd5c63 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2024.5.0", + "version": "2024.6.0", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/apps/web/src/app/admin-console/common/base.events.component.ts b/apps/web/src/app/admin-console/common/base.events.component.ts index e14bb62a35da..12c051271e17 100644 --- a/apps/web/src/app/admin-console/common/base.events.component.ts +++ b/apps/web/src/app/admin-console/common/base.events.component.ts @@ -97,19 +97,15 @@ export abstract class BaseEventsComponent { this.loading = true; let events: EventView[] = []; let promise: Promise; - try { - promise = this.loadAndParseEvents( - dates[0], - dates[1], - clearExisting ? null : this.continuationToken, - ); + promise = this.loadAndParseEvents( + dates[0], + dates[1], + clearExisting ? null : this.continuationToken, + ); - const result = await promise; - this.continuationToken = result.continuationToken; - events = result.events; - } catch (e) { - this.logService.error(`Handled exception: ${e}`); - } + const result = await promise; + this.continuationToken = result.continuationToken; + events = result.events; if (!clearExisting && this.events != null && this.events.length > 0) { this.events = this.events.concat(events); diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index d1a48a78e11b..237e2c6e30c8 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -52,7 +52,7 @@ *ngIf="canShowBillingTab(organization)" > - + diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index 47ca0998bbcc..4383656bee1e 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -1,7 +1,7 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, RouterModule } from "@angular/router"; -import { map, mergeMap, Observable, Subject, takeUntil } from "rxjs"; +import { combineLatest, map, mergeMap, Observable, Subject, switchMap, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -16,7 +16,8 @@ import { OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { PolicyType, ProviderStatusType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -55,9 +56,14 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy { organization$: Observable; showPaymentAndHistory$: Observable; hideNewOrgButton$: Observable; + organizationIsUnmanaged$: Observable; private _destroy = new Subject(); + protected consolidatedBillingEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.EnableConsolidatedBilling, + ); + protected showPaymentMethodWarningBanners$ = this.configService.getFeatureFlag$( FeatureFlag.ShowPaymentMethodWarningBanners, ); @@ -68,6 +74,7 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy { private platformUtilsService: PlatformUtilsService, private configService: ConfigService, private policyService: PolicyService, + private providerService: ProviderService, ) {} async ngOnInit() { @@ -94,6 +101,24 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy { ); this.hideNewOrgButton$ = this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg); + + const provider$ = this.organization$.pipe( + switchMap((organization) => this.providerService.get$(organization.providerId)), + ); + + this.organizationIsUnmanaged$ = combineLatest([ + this.consolidatedBillingEnabled$, + this.organization$, + provider$, + ]).pipe( + map( + ([consolidatedBillingEnabled, organization, provider]) => + !consolidatedBillingEnabled || + !organization.hasProvider || + !provider || + provider.providerStatus !== ProviderStatusType.Billable, + ), + ); } ngOnDestroy() { diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html index 149277b81793..2a092e261009 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html @@ -144,6 +144,7 @@

- - {{ "editPolicy" | i18n }} - {{ policy.name | i18n }} - +
{ - comp.keyType = "organization"; - comp.entityId = this.organizationId; - comp.postKey = this.organizationApiService.getOrCreateApiKey.bind( - this.organizationApiService, - ); - comp.scope = "api.organization"; - comp.grantType = "client_credentials"; - comp.apiKeyTitle = "apiKey"; - comp.apiKeyWarning = "apiKeyWarning"; - comp.apiKeyDescription = "apiKeyDesc"; + await ApiKeyComponent.open(this.dialogService, { + data: { + keyType: "organization", + entityId: this.organizationId, + postKey: this.organizationApiService.getOrCreateApiKey.bind(this.organizationApiService), + scope: "api.organization", + grantType: "client_credentials", + apiKeyTitle: "apiKey", + apiKeyWarning: "apiKeyWarning", + apiKeyDescription: "apiKeyDesc", + }, }); } async rotateApiKey() { - await this.modalService.openViewRef(ApiKeyComponent, this.rotateApiKeyModalRef, (comp) => { - comp.keyType = "organization"; - comp.isRotation = true; - comp.entityId = this.organizationId; - comp.postKey = this.organizationApiService.rotateApiKey.bind(this.organizationApiService); - comp.scope = "api.organization"; - comp.grantType = "client_credentials"; - comp.apiKeyTitle = "apiKey"; - comp.apiKeyWarning = "apiKeyWarning"; - comp.apiKeyDescription = "apiKeyRotateDesc"; + await ApiKeyComponent.open(this.dialogService, { + data: { + keyType: "organization", + isRotation: true, + entityId: this.organizationId, + postKey: this.organizationApiService.rotateApiKey.bind(this.organizationApiService), + scope: "api.organization", + grantType: "client_credentials", + apiKeyTitle: "apiKey", + apiKeyWarning: "apiKeyWarning", + apiKeyDescription: "apiKeyRotateDesc", + }, }); } } diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 6c71309243eb..c9fbf359f0f6 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -14,6 +14,7 @@ import { timer, } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -34,13 +35,13 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; import { StateEventRunnerService } from "@bitwarden/common/platform/state"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { DialogService, ToastOptions, ToastService } from "@bitwarden/components"; import { PolicyListService } from "./admin-console/core/policy-list.service"; import { @@ -148,7 +149,7 @@ export class AppComponent implements OnDestroy, OnInit { this.router.navigate(["/"]); break; case "logout": - await this.logOut(!!message.expired, message.redirect); + await this.logOut(message.logoutReason, message.redirect); break; case "lockVault": await this.vaultTimeoutService.lock(); @@ -278,7 +279,34 @@ export class AppComponent implements OnDestroy, OnInit { this.destroy$.complete(); } - private async logOut(expired: boolean, redirect = true) { + private async displayLogoutReason(logoutReason: LogoutReason) { + let toastOptions: ToastOptions; + switch (logoutReason) { + case "invalidSecurityStamp": + case "sessionExpired": { + toastOptions = { + variant: "warning", + title: this.i18nService.t("loggedOut"), + message: this.i18nService.t("loginExpired"), + }; + break; + } + default: { + toastOptions = { + variant: "info", + title: this.i18nService.t("loggedOut"), + message: this.i18nService.t("loggedOutDesc"), + }; + break; + } + } + + this.toastService.showToast(toastOptions); + } + + private async logOut(logoutReason: LogoutReason, redirect = true) { + await this.displayLogoutReason(logoutReason); + await this.eventUploadService.uploadEvents(); const userId = (await this.stateService.getUserId()) as UserId; @@ -308,14 +336,6 @@ export class AppComponent implements OnDestroy, OnInit { await this.searchService.clearIndex(); this.authService.logOut(async () => { - if (expired) { - this.platformUtilsService.showToast( - "warning", - this.i18nService.t("loggedOut"), - this.i18nService.t("loginExpired"), - ); - } - await this.stateService.clean({ userId: userId }); await this.accountService.clean(userId); diff --git a/apps/web/src/app/auth/accept-organization.component.ts b/apps/web/src/app/auth/accept-organization.component.ts deleted file mode 100644 index 52e3b6449402..000000000000 --- a/apps/web/src/app/auth/accept-organization.component.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { Component } from "@angular/core"; -import { ActivatedRoute, Params, Router } from "@angular/router"; - -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 { - OrganizationUserAcceptInitRequest, - OrganizationUserAcceptRequest, -} from "@bitwarden/common/admin-console/abstractions/organization-user/requests"; -import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { 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 { Utils } from "@bitwarden/common/platform/misc/utils"; -import { OrgKey } from "@bitwarden/common/types/key"; - -import { BaseAcceptComponent } from "../common/base.accept.component"; - -@Component({ - selector: "app-accept-organization", - templateUrl: "accept-organization.component.html", -}) -export class AcceptOrganizationComponent extends BaseAcceptComponent { - orgName: string; - - protected requiredParameters: string[] = ["organizationId", "organizationUserId", "token"]; - - constructor( - router: Router, - platformUtilsService: PlatformUtilsService, - i18nService: I18nService, - route: ActivatedRoute, - stateService: StateService, - private cryptoService: CryptoService, - private policyApiService: PolicyApiServiceAbstraction, - private policyService: PolicyService, - private logService: LogService, - private organizationApiService: OrganizationApiServiceAbstraction, - private organizationUserService: OrganizationUserService, - private messagingService: MessagingService, - private apiService: ApiService, - ) { - super(router, platformUtilsService, i18nService, route, stateService); - } - - async authedHandler(qParams: Params): Promise { - const initOrganization = - qParams.initOrganization != null && qParams.initOrganization.toLocaleLowerCase() === "true"; - if (initOrganization) { - this.actionPromise = this.acceptInitOrganizationFlow(qParams); - } else { - const needsReAuth = (await this.stateService.getOrganizationInvitation()) == null; - if (needsReAuth) { - // Accepting an org invite requires authentication from a logged out state - this.messagingService.send("logout", { redirect: false }); - await this.prepareOrganizationInvitation(qParams); - return; - } - - // User has already logged in and passed the Master Password policy check - this.actionPromise = this.acceptFlow(qParams); - } - - await this.actionPromise; - await this.apiService.refreshIdentityToken(); - await this.stateService.setOrganizationInvitation(null); - this.platformUtilService.showToast( - "success", - this.i18nService.t("inviteAccepted"), - initOrganization - ? this.i18nService.t("inviteInitAcceptedDesc") - : this.i18nService.t("inviteAcceptedDesc"), - { timeout: 10000 }, - ); - // 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(["/vault"]); - } - - async unauthedHandler(qParams: Params): Promise { - await this.prepareOrganizationInvitation(qParams); - - // In certain scenarios, we want to accelerate the user through the accept org invite process - // For example, if the user has a BW account already, we want them to be taken to login instead of creation. - await this.accelerateInviteAcceptIfPossible(qParams); - } - - private async acceptInitOrganizationFlow(qParams: Params): Promise { - return this.prepareAcceptInitRequest(qParams).then((request) => - this.organizationUserService.postOrganizationUserAcceptInit( - qParams.organizationId, - qParams.organizationUserId, - request, - ), - ); - } - - private async acceptFlow(qParams: Params): Promise { - return this.prepareAcceptRequest(qParams).then((request) => - this.organizationUserService.postOrganizationUserAccept( - qParams.organizationId, - qParams.organizationUserId, - request, - ), - ); - } - - private async prepareAcceptInitRequest( - qParams: Params, - ): Promise { - const request = new OrganizationUserAcceptInitRequest(); - request.token = qParams.token; - - const [encryptedOrgKey, orgKey] = await this.cryptoService.makeOrgKey(); - const [orgPublicKey, encryptedOrgPrivateKey] = await this.cryptoService.makeKeyPair(orgKey); - const collection = await this.cryptoService.encrypt( - this.i18nService.t("defaultCollection"), - orgKey, - ); - - request.key = encryptedOrgKey.encryptedString; - request.keys = new OrganizationKeysRequest( - orgPublicKey, - encryptedOrgPrivateKey.encryptedString, - ); - request.collectionName = collection.encryptedString; - - return request; - } - - private async prepareAcceptRequest(qParams: Params): Promise { - const request = new OrganizationUserAcceptRequest(); - request.token = qParams.token; - - if (await this.performResetPasswordAutoEnroll(qParams)) { - const response = await this.organizationApiService.getKeys(qParams.organizationId); - - if (response == null) { - throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); - } - - const publicKey = Utils.fromB64ToArray(response.publicKey); - - // RSA Encrypt user's encKey.key with organization public key - const userKey = await this.cryptoService.getUserKey(); - const encryptedKey = await this.cryptoService.rsaEncrypt(userKey.key, publicKey); - - // Add reset password key to accept request - request.resetPasswordKey = encryptedKey.encryptedString; - } - return request; - } - - private async performResetPasswordAutoEnroll(qParams: Params): Promise { - let policyList: Policy[] = null; - try { - const policies = await this.policyApiService.getPoliciesByToken( - qParams.organizationId, - qParams.token, - qParams.email, - qParams.organizationUserId, - ); - policyList = Policy.fromListResponse(policies); - } catch (e) { - this.logService.error(e); - } - - if (policyList != null) { - const result = this.policyService.getResetPasswordPolicyOptions( - policyList, - qParams.organizationId, - ); - // Return true if policy enabled and auto-enroll enabled - return result[1] && result[0].autoEnrollEnabled; - } - - return false; - } - - private async prepareOrganizationInvitation(qParams: Params): Promise { - this.orgName = qParams.organizationName; - if (this.orgName != null) { - // Fix URL encoding of space issue with Angular - this.orgName = this.orgName.replace(/\+/g, " "); - } - await this.stateService.setOrganizationInvitation(qParams); - } - - private async accelerateInviteAcceptIfPossible(qParams: Params): Promise { - // Extract the query params we need to make routing acceleration decisions - const orgSsoIdentifier = qParams.orgSsoIdentifier; - const orgUserHasExistingUser = this.stringToNullOrBool(qParams.orgUserHasExistingUser); - - // if orgUserHasExistingUser is null, short circuit for backwards compatibility w/ older servers - if (orgUserHasExistingUser == null) { - return; - } - - // if user exists, send user to login - if (orgUserHasExistingUser) { - // 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: qParams.email }, - }); - return; - } - - // no user exists; so either sign in via SSO and JIT provision one or simply register. - - if (orgSsoIdentifier) { - // We only send sso org identifier if the org has SSO enabled and the SSO policy required. - // 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(["/sso"], { - queryParams: { email: qParams.email, identifier: orgSsoIdentifier }, - }); - return; - } - - // if SSO is disabled OR if sso is enabled but the SSO login required policy is not enabled - // then send user to create account - // 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(["/register"], { - queryParams: { email: qParams.email, fromOrgInvite: true }, - }); - return; - } - - private stringToNullOrBool(s: string | undefined): boolean | null { - if (s === undefined) { - return null; - } - return s.toLowerCase() === "true"; - } -} diff --git a/apps/web/src/app/auth/auth.module.ts b/apps/web/src/app/auth/auth.module.ts index 056b9f161f93..6aa671558a08 100644 --- a/apps/web/src/app/auth/auth.module.ts +++ b/apps/web/src/app/auth/auth.module.ts @@ -1,9 +1,10 @@ import { NgModule } from "@angular/core"; +import { AcceptOrganizationInviteModule } from "./organization-invite/accept-organization.module"; import { AuthSettingsModule } from "./settings/settings.module"; @NgModule({ - imports: [AuthSettingsModule], + imports: [AuthSettingsModule, AcceptOrganizationInviteModule], declarations: [], providers: [], exports: [AuthSettingsModule], diff --git a/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts b/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts index ef3d657f2f9c..f7c391b0ee28 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts @@ -20,8 +20,8 @@ export class WebauthnLoginAttestationResponseRequest extends WebauthnLoginAuthen } this.response = { - attestationObject: Utils.fromBufferToB64(credential.response.attestationObject), - clientDataJson: Utils.fromBufferToB64(credential.response.clientDataJSON), + attestationObject: Utils.fromBufferToUrlB64(credential.response.attestationObject), + clientDataJson: Utils.fromBufferToUrlB64(credential.response.clientDataJSON), }; } } diff --git a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.html b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.html index 4690a4e63a54..3e1db4063166 100644 --- a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.html +++ b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.html @@ -1,45 +1,39 @@ -
+
- -

+

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

-
-
-
-

{{ "emergencyAccess" | i18n }}

-
-
-

- {{ name }} -

-

{{ "acceptEmergencyAccess" | i18n }}

-
- -
-
-
+
+

+ {{ name }} +

+

{{ "acceptEmergencyAccess" | i18n }}

+
+
diff --git a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts index 8ff847c3a22c..5a92815c91fa 100644 --- a/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts +++ b/apps/web/src/app/auth/emergency-access/accept/accept-emergency.component.ts @@ -1,9 +1,9 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.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 { BaseAcceptComponent } from "../../../common/base.accept.component"; import { SharedModule } from "../../../shared"; @@ -27,10 +27,10 @@ export class AcceptEmergencyComponent extends BaseAcceptComponent { platformUtilsService: PlatformUtilsService, i18nService: I18nService, route: ActivatedRoute, - stateService: StateService, + authService: AuthService, private emergencyAccessService: EmergencyAccessService, ) { - super(router, platformUtilsService, i18nService, route, stateService); + super(router, platformUtilsService, i18nService, route, authService); } async authedHandler(qParams: Params): Promise { diff --git a/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.ts b/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.ts index 2c97bd227f9e..991fe8b59710 100644 --- a/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.ts +++ b/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.ts @@ -1,14 +1,27 @@ -import { Component } from "@angular/core"; +import { Component, inject } from "@angular/core"; import { BaseLoginDecryptionOptionsComponent } from "@bitwarden/angular/auth/components/base-login-decryption-options.component"; + +import { RouterService } from "../../../core"; +import { AcceptOrganizationInviteService } from "../../organization-invite/accept-organization.service"; @Component({ selector: "web-login-decryption-options", templateUrl: "login-decryption-options.component.html", }) export class LoginDecryptionOptionsComponent extends BaseLoginDecryptionOptionsComponent { + protected routerService = inject(RouterService); + protected acceptOrganizationInviteService = inject(AcceptOrganizationInviteService); + override async createUser(): Promise { try { await super.createUser(); + + // Invites from TDE orgs go through here, but the invite is + // accepted while being enrolled in admin recovery. So we need to clear + // the redirect and stored org invite. + await this.routerService.getAndClearLoginRedirectUrl(); + await this.acceptOrganizationInviteService.clearOrganizationInvitation(); + await this.router.navigate(["/vault"]); } catch (error) { this.validationService.showError(error); diff --git a/apps/web/src/app/auth/login/login.component.ts b/apps/web/src/app/auth/login/login.component.ts index 9f628b9389e2..51d46f46a420 100644 --- a/apps/web/src/app/auth/login/login.component.ts +++ b/apps/web/src/app/auth/login/login.component.ts @@ -15,12 +15,10 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; 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 { 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"; -import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -32,6 +30,8 @@ import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/pass import { flagEnabled } from "../../../utils/flags"; import { RouterService, StateService } from "../../core"; +import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service"; +import { OrganizationInvite } from "../organization-invite/organization-invite"; @Component({ selector: "app-login", @@ -41,10 +41,11 @@ import { RouterService, StateService } from "../../core"; export class LoginComponent extends BaseLoginComponent implements OnInit { showResetPasswordAutoEnrollWarning = false; enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions; - policies: ListResponse; + policies: Policy[]; showPasswordless = false; constructor( + private acceptOrganizationInviteService: AcceptOrganizationInviteService, devicesApiService: DevicesApiServiceAbstraction, appIdService: AppIdService, loginStrategyService: LoginStrategyServiceAbstraction, @@ -112,37 +113,10 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { await super.ngOnInit(); }); - const invite = await this.stateService.getOrganizationInvitation(); - if (invite != null) { - let policyList: Policy[] = null; - try { - this.policies = await this.policyApiService.getPoliciesByToken( - invite.organizationId, - invite.token, - invite.email, - invite.organizationUserId, - ); - policyList = Policy.fromListResponse(this.policies); - } catch (e) { - this.logService.error(e); - } - - if (policyList != null) { - const resetPasswordPolicy = this.policyService.getResetPasswordPolicyOptions( - policyList, - invite.organizationId, - ); - // Set to true if policy enabled and auto-enroll enabled - this.showResetPasswordAutoEnrollWarning = - resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled; - - this.policyService - .masterPasswordPolicyOptions$(policyList) - .pipe(takeUntil(this.destroy$)) - .subscribe((enforcedPasswordPolicyOptions) => { - this.enforcedPasswordPolicyOptions = enforcedPasswordPolicyOptions; - }); - } + // If there's an existing org invite, use it to get the password policies + const orgInvite = await this.acceptOrganizationInviteService.getOrganizationInvite(); + if (orgInvite != null) { + await this.initPasswordPolicies(orgInvite); } } @@ -166,50 +140,69 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { ) ) { const policiesData: { [id: string]: PolicyData } = {}; - this.policies.data.map((p) => (policiesData[p.id] = new PolicyData(p))); + this.policies.map((p) => (policiesData[p.id] = PolicyData.fromPolicy(p))); await this.policyService.replace(policiesData); - // 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(["update-password"]); + await this.router.navigate(["update-password"]); return; } } 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]); + await this.router.navigate([this.successRoute]); } - goToHint() { + async goToHint() { 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"); + await this.router.navigateByUrl("/hint"); } - goToRegister() { + async goToRegister() { const email = this.formGroup.value.email; if (email) { - // 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(["/register"], { queryParams: { email: email } }); + await this.router.navigate(["/register"], { queryParams: { email: email } }); 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.router.navigate(["/register"]); + await this.router.navigate(["/register"]); } - protected override handleMigrateEncryptionKey(result: AuthResult): boolean { + protected override async handleMigrateEncryptionKey(result: AuthResult): Promise { if (!result.requiresEncryptionKeyMigration) { return false; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["migrate-legacy-encryption"]); + await this.router.navigate(["migrate-legacy-encryption"]); return true; } + + private async initPasswordPolicies(invite: OrganizationInvite): Promise { + try { + this.policies = await this.policyApiService.getPoliciesByToken( + invite.organizationId, + invite.token, + invite.email, + invite.organizationUserId, + ); + } catch (e) { + this.logService.error(e); + } + + if (this.policies == null) { + return; + } + const resetPasswordPolicy = this.policyService.getResetPasswordPolicyOptions( + this.policies, + invite.organizationId, + ); + // Set to true if policy enabled and auto-enroll enabled + this.showResetPasswordAutoEnrollWarning = + resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled; + + this.policyService + .masterPasswordPolicyOptions$(this.policies) + .pipe(takeUntil(this.destroy$)) + .subscribe((enforcedPasswordPolicyOptions) => { + this.enforcedPasswordPolicyOptions = enforcedPasswordPolicyOptions; + }); + } } diff --git a/apps/web/src/app/auth/accept-organization.component.html b/apps/web/src/app/auth/organization-invite/accept-organization.component.html similarity index 97% rename from apps/web/src/app/auth/accept-organization.component.html rename to apps/web/src/app/auth/organization-invite/accept-organization.component.html index 3aef47df22b6..f9dd3da5ed9a 100644 --- a/apps/web/src/app/auth/accept-organization.component.html +++ b/apps/web/src/app/auth/organization-invite/accept-organization.component.html @@ -18,7 +18,7 @@

- {{ orgName }} + {{ orgName$ | async }} {{ email }}

{{ "joinOrganizationDesc" | i18n }}

diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.component.ts b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts new file mode 100644 index 000000000000..fa5507b216fe --- /dev/null +++ b/apps/web/src/app/auth/organization-invite/accept-organization.component.ts @@ -0,0 +1,94 @@ +import { Component } from "@angular/core"; +import { ActivatedRoute, Params, Router } from "@angular/router"; + +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { BaseAcceptComponent } from "../../common/base.accept.component"; + +import { AcceptOrganizationInviteService } from "./accept-organization.service"; +import { OrganizationInvite } from "./organization-invite"; + +@Component({ + templateUrl: "accept-organization.component.html", +}) +export class AcceptOrganizationComponent extends BaseAcceptComponent { + orgName$ = this.acceptOrganizationInviteService.orgName$; + protected requiredParameters: string[] = ["organizationId", "organizationUserId", "token"]; + + constructor( + router: Router, + platformUtilsService: PlatformUtilsService, + i18nService: I18nService, + route: ActivatedRoute, + authService: AuthService, + private acceptOrganizationInviteService: AcceptOrganizationInviteService, + ) { + super(router, platformUtilsService, i18nService, route, authService); + } + + async authedHandler(qParams: Params): Promise { + const invite = OrganizationInvite.fromParams(qParams); + const success = await this.acceptOrganizationInviteService.validateAndAcceptInvite(invite); + + if (!success) { + return; + } + + this.platformUtilService.showToast( + "success", + this.i18nService.t("inviteAccepted"), + invite.initOrganization + ? this.i18nService.t("inviteInitAcceptedDesc") + : this.i18nService.t("inviteAcceptedDesc"), + { timeout: 10000 }, + ); + + await this.router.navigate(["/vault"]); + } + + async unauthedHandler(qParams: Params): Promise { + const invite = OrganizationInvite.fromParams(qParams); + await this.acceptOrganizationInviteService.setOrganizationInvitation(invite); + await this.accelerateInviteAcceptIfPossible(invite); + } + + /** + * In certain scenarios, we want to accelerate the user through the accept org invite process + * For example, if the user has a BW account already, we want them to be taken to login instead of creation. + */ + private async accelerateInviteAcceptIfPossible(invite: OrganizationInvite): Promise { + // if orgUserHasExistingUser is null, we can't determine the user's status + // so we don't want to accelerate the process + if (invite.orgUserHasExistingUser == null) { + return; + } + + // if user exists, send user to login + if (invite.orgUserHasExistingUser) { + await this.router.navigate(["/login"], { + queryParams: { email: invite.email }, + }); + return; + } + + if (invite.orgSsoIdentifier) { + // We only send sso org identifier if the org has SSO enabled and the SSO policy required. + // Will JIT provision the user. + // Note: If the organization has Admin Recovery enabled, the user will be accepted into the org + // upon enrollment. The user should not be returned here. + await this.router.navigate(["/sso"], { + queryParams: { email: invite.email, identifier: invite.orgSsoIdentifier }, + }); + return; + } + + // if SSO is disabled OR if sso is enabled but the SSO login required policy is not enabled + // then send user to create account + await this.router.navigate(["/register"], { + queryParams: { email: invite.email, fromOrgInvite: true }, + }); + return; + } +} diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.module.ts b/apps/web/src/app/auth/organization-invite/accept-organization.module.ts new file mode 100644 index 000000000000..3dc0e1448912 --- /dev/null +++ b/apps/web/src/app/auth/organization-invite/accept-organization.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from "@angular/core"; + +import { SharedModule } from "../../shared"; + +import { AcceptOrganizationComponent } from "./accept-organization.component"; +import { AcceptOrganizationInviteService } from "./accept-organization.service"; + +@NgModule({ + declarations: [AcceptOrganizationComponent], + imports: [SharedModule], + providers: [AcceptOrganizationInviteService], +}) +export class AcceptOrganizationInviteModule {} diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts b/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts new file mode 100644 index 000000000000..97a17a5997fb --- /dev/null +++ b/apps/web/src/app/auth/organization-invite/accept-organization.service.spec.ts @@ -0,0 +1,185 @@ +import { FakeGlobalStateProvider } from "@bitwarden/common/../spec/fake-state-provider"; +import { MockProxy, mock } from "jest-mock-extended"; + +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 { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { FakeGlobalState } from "@bitwarden/common/spec/fake-state"; +import { OrgKey } from "@bitwarden/common/types/key"; + +import { I18nService } from "../../core/i18n.service"; + +import { + AcceptOrganizationInviteService, + ORGANIZATION_INVITE, +} from "./accept-organization.service"; +import { OrganizationInvite } from "./organization-invite"; + +describe("AcceptOrganizationInviteService", () => { + let sut: AcceptOrganizationInviteService; + let apiService: MockProxy; + let authService: MockProxy; + let cryptoService: MockProxy; + let encryptService: MockProxy; + let policyApiService: MockProxy; + let policyService: MockProxy; + let logService: MockProxy; + let organizationApiService: MockProxy; + let organizationUserService: MockProxy; + let i18nService: MockProxy; + let globalStateProvider: FakeGlobalStateProvider; + let globalState: FakeGlobalState; + + beforeEach(() => { + apiService = mock(); + authService = mock(); + cryptoService = mock(); + encryptService = mock(); + policyApiService = mock(); + policyService = mock(); + logService = mock(); + organizationApiService = mock(); + organizationUserService = mock(); + i18nService = mock(); + globalStateProvider = new FakeGlobalStateProvider(); + globalState = globalStateProvider.getFake(ORGANIZATION_INVITE); + + sut = new AcceptOrganizationInviteService( + apiService, + authService, + cryptoService, + encryptService, + policyApiService, + policyService, + logService, + organizationApiService, + organizationUserService, + i18nService, + globalStateProvider, + ); + }); + + describe("validateAndAcceptInvite", () => { + it("initializes an organization when given an invite where initOrganization is true", async () => { + cryptoService.makeOrgKey.mockResolvedValue([ + { encryptedString: "string" } as EncString, + "orgPrivateKey" as unknown as OrgKey, + ]); + cryptoService.makeKeyPair.mockResolvedValue([ + "orgPublicKey", + { encryptedString: "string" } as EncString, + ]); + encryptService.encrypt.mockResolvedValue({ encryptedString: "string" } as EncString); + const invite = createOrgInvite({ initOrganization: true }); + + const result = await sut.validateAndAcceptInvite(invite); + + expect(result).toBe(true); + expect(organizationUserService.postOrganizationUserAcceptInit).toHaveBeenCalled(); + expect(apiService.refreshIdentityToken).toHaveBeenCalled(); + expect(globalState.nextMock).toHaveBeenCalledWith(null); + expect(organizationUserService.postOrganizationUserAccept).not.toHaveBeenCalled(); + expect(authService.logOut).not.toHaveBeenCalled(); + }); + + it("logs out the user and stores the invite when a master password policy check is required", async () => { + const invite = createOrgInvite(); + policyApiService.getPoliciesByToken.mockResolvedValue([ + { + type: PolicyType.MasterPassword, + enabled: true, + } as Policy, + ]); + + const result = await sut.validateAndAcceptInvite(invite); + + expect(result).toBe(false); + expect(authService.logOut).toHaveBeenCalled(); + expect(globalState.nextMock).toHaveBeenCalledWith(invite); + }); + + it("clears the stored invite when a master password policy check is required but the stored invite doesn't match the provided one", async () => { + const storedInvite = createOrgInvite({ email: "wrongemail@example.com" }); + const providedInvite = createOrgInvite(); + await globalState.update(() => storedInvite); + policyApiService.getPoliciesByToken.mockResolvedValue([ + { + type: PolicyType.MasterPassword, + enabled: true, + } as Policy, + ]); + + const result = await sut.validateAndAcceptInvite(providedInvite); + + expect(result).toBe(false); + expect(authService.logOut).toHaveBeenCalled(); + expect(globalState.nextMock).toHaveBeenCalledWith(providedInvite); + }); + + it("accepts the invitation request when the organization doesn't have a master password policy", async () => { + const invite = createOrgInvite(); + policyApiService.getPoliciesByToken.mockResolvedValue([]); + + const result = await sut.validateAndAcceptInvite(invite); + + expect(result).toBe(true); + expect(organizationUserService.postOrganizationUserAccept).toHaveBeenCalled(); + expect(apiService.refreshIdentityToken).toHaveBeenCalled(); + expect(globalState.nextMock).toHaveBeenCalledWith(null); + expect(organizationUserService.postOrganizationUserAcceptInit).not.toHaveBeenCalled(); + expect(authService.logOut).not.toHaveBeenCalled(); + }); + + it("accepts the invitation request when the org has a master password policy, but the user has already passed it", async () => { + const invite = createOrgInvite(); + policyApiService.getPoliciesByToken.mockResolvedValue([ + { + type: PolicyType.MasterPassword, + enabled: true, + } as Policy, + ]); + // an existing invite means the user has already passed the master password policy + await globalState.update(() => invite); + + policyService.getResetPasswordPolicyOptions.mockReturnValue([ + { + autoEnrollEnabled: false, + } as ResetPasswordPolicyOptions, + false, + ]); + + const result = await sut.validateAndAcceptInvite(invite); + + expect(result).toBe(true); + expect(organizationUserService.postOrganizationUserAccept).toHaveBeenCalled(); + expect(organizationUserService.postOrganizationUserAcceptInit).not.toHaveBeenCalled(); + expect(authService.logOut).not.toHaveBeenCalled(); + }); + }); +}); + +function createOrgInvite(custom: Partial = {}): OrganizationInvite { + return Object.assign( + { + email: "user@example.com", + initOrganization: false, + orgSsoIdentifier: null, + orgUserHasExistingUser: false, + organizationId: "organizationId", + organizationName: "organizationName", + organizationUserId: "organizationUserId", + token: "token", + }, + custom, + ); +} diff --git a/apps/web/src/app/auth/organization-invite/accept-organization.service.ts b/apps/web/src/app/auth/organization-invite/accept-organization.service.ts new file mode 100644 index 000000000000..e43023c37d7e --- /dev/null +++ b/apps/web/src/app/auth/organization-invite/accept-organization.service.ts @@ -0,0 +1,248 @@ +import { Injectable } from "@angular/core"; +import { BehaviorSubject, firstValueFrom, map } from "rxjs"; + +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 { + OrganizationUserAcceptRequest, + OrganizationUserAcceptInitRequest, +} from "@bitwarden/common/admin-console/abstractions/organization-user/requests"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + GlobalState, + GlobalStateProvider, + KeyDefinition, + ORGANIZATION_INVITE_DISK, +} from "@bitwarden/common/platform/state"; +import { OrgKey } from "@bitwarden/common/types/key"; + +import { OrganizationInvite } from "./organization-invite"; + +// We're storing the organization invite for 2 reasons: +// 1. If the org requires a MP policy check, we need to keep track that the user has already been redirected when they return. +// 2. The MP policy check happens on login/register flows, we need to store the token to retrieve the policies then. +export const ORGANIZATION_INVITE = new KeyDefinition( + ORGANIZATION_INVITE_DISK, + "organizationInvite", + { + deserializer: (invite) => OrganizationInvite.fromJSON(invite), + }, +); + +@Injectable() +export class AcceptOrganizationInviteService { + private organizationInvitationState: GlobalState; + private orgNameSubject: BehaviorSubject = new BehaviorSubject(null); + private policyCache: Policy[]; + + // Fix URL encoding of space issue with Angular + orgName$ = this.orgNameSubject.pipe(map((orgName) => orgName.replace(/\+/g, " "))); + + constructor( + private readonly apiService: ApiService, + private readonly authService: AuthService, + private readonly cryptoService: CryptoService, + private readonly encryptService: EncryptService, + private readonly policyApiService: PolicyApiServiceAbstraction, + private readonly policyService: PolicyService, + private readonly logService: LogService, + private readonly organizationApiService: OrganizationApiServiceAbstraction, + private readonly organizationUserService: OrganizationUserService, + private readonly i18nService: I18nService, + private readonly globalStateProvider: GlobalStateProvider, + ) { + this.organizationInvitationState = this.globalStateProvider.get(ORGANIZATION_INVITE); + } + + /** Returns the currently stored organization invite */ + async getOrganizationInvite(): Promise { + return await firstValueFrom(this.organizationInvitationState.state$); + } + + /** + * Stores a new organization invite + * @param invite an organization invite + * @throws if the invite is nullish + */ + async setOrganizationInvitation(invite: OrganizationInvite): Promise { + if (invite == null) { + throw new Error("Invite cannot be null. Use clearOrganizationInvitation instead."); + } + await this.organizationInvitationState.update(() => invite); + } + + /** Clears the currently stored organization invite */ + async clearOrganizationInvitation(): Promise { + await this.organizationInvitationState.update(() => null); + } + + /** + * Validates and accepts the organization invitation if possible. + * Note: Users might need to pass a MP policy check before accepting an invite to an existing organization. If the user + * has not passed this check, they will be logged out and the invite will be stored for later use. + * @param invite an organization invite + * @returns a promise that resolves a boolean indicating if the invite was accepted. + */ + async validateAndAcceptInvite(invite: OrganizationInvite): Promise { + if (invite == null) { + throw new Error("Invite cannot be null."); + } + + // Creation of a new org + if (invite.initOrganization) { + await this.acceptAndInitOrganization(invite); + return true; + } + + // Accepting an org invite from existing org + if (await this.masterPasswordPolicyCheckRequired(invite)) { + await this.setOrganizationInvitation(invite); + this.authService.logOut(() => { + /* Do nothing */ + }); + return false; + } + + // We know the user has already logged in and passed a MP policy check + await this.accept(invite); + return true; + } + + private async acceptAndInitOrganization(invite: OrganizationInvite): Promise { + await this.prepareAcceptAndInitRequest(invite).then((request) => + this.organizationUserService.postOrganizationUserAcceptInit( + invite.organizationId, + invite.organizationUserId, + request, + ), + ); + await this.apiService.refreshIdentityToken(); + await this.clearOrganizationInvitation(); + } + + private async prepareAcceptAndInitRequest( + invite: OrganizationInvite, + ): Promise { + const request = new OrganizationUserAcceptInitRequest(); + request.token = invite.token; + + const [encryptedOrgKey, orgKey] = await this.cryptoService.makeOrgKey(); + const [orgPublicKey, encryptedOrgPrivateKey] = await this.cryptoService.makeKeyPair(orgKey); + const collection = await this.encryptService.encrypt( + this.i18nService.t("defaultCollection"), + orgKey, + ); + + request.key = encryptedOrgKey.encryptedString; + request.keys = new OrganizationKeysRequest( + orgPublicKey, + encryptedOrgPrivateKey.encryptedString, + ); + request.collectionName = collection.encryptedString; + + return request; + } + + private async accept(invite: OrganizationInvite): Promise { + await this.prepareAcceptRequest(invite).then((request) => + this.organizationUserService.postOrganizationUserAccept( + invite.organizationId, + invite.organizationUserId, + request, + ), + ); + + await this.apiService.refreshIdentityToken(); + await this.clearOrganizationInvitation(); + } + + private async prepareAcceptRequest( + invite: OrganizationInvite, + ): Promise { + const request = new OrganizationUserAcceptRequest(); + request.token = invite.token; + + if (await this.resetPasswordEnrollRequired(invite)) { + const response = await this.organizationApiService.getKeys(invite.organizationId); + + if (response == null) { + throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); + } + + const publicKey = Utils.fromB64ToArray(response.publicKey); + + // RSA Encrypt user's encKey.key with organization public key + const userKey = await this.cryptoService.getUserKey(); + const encryptedKey = await this.cryptoService.rsaEncrypt(userKey.key, publicKey); + + // Add reset password key to accept request + request.resetPasswordKey = encryptedKey.encryptedString; + } + return request; + } + + private async resetPasswordEnrollRequired(invite: OrganizationInvite): Promise { + const policies = await this.getPolicies(invite); + + if (policies == null || policies.length === 0) { + return false; + } + + const result = this.policyService.getResetPasswordPolicyOptions( + policies, + invite.organizationId, + ); + // Return true if policy enabled and auto-enroll enabled + return result[1] && result[0].autoEnrollEnabled; + } + + private async masterPasswordPolicyCheckRequired(invite: OrganizationInvite): Promise { + const policies = await this.getPolicies(invite); + + if (policies == null || policies.length === 0) { + return false; + } + const hasMasterPasswordPolicy = policies.some( + (p) => p.type === PolicyType.MasterPassword && p.enabled, + ); + + let storedInvite = await this.getOrganizationInvite(); + if (storedInvite?.email !== invite.email) { + // clear stored invites if the email doesn't match + await this.clearOrganizationInvitation(); + storedInvite = null; + } + // if we don't have an org invite stored, we know the user hasn't been redirected yet to check the MP policy + const hasNotCheckedMasterPasswordYet = storedInvite == null; + return hasMasterPasswordPolicy && hasNotCheckedMasterPasswordYet; + } + + private async getPolicies(invite: OrganizationInvite): Promise { + // if policies are not cached, fetch them + if (this.policyCache == null) { + try { + this.policyCache = await this.policyApiService.getPoliciesByToken( + invite.organizationId, + invite.token, + invite.email, + invite.organizationUserId, + ); + } catch (e) { + this.logService.error(e); + } + } + + return this.policyCache; + } +} diff --git a/apps/web/src/app/auth/organization-invite/organization-invite.ts b/apps/web/src/app/auth/organization-invite/organization-invite.ts new file mode 100644 index 000000000000..9a0bbf83348e --- /dev/null +++ b/apps/web/src/app/auth/organization-invite/organization-invite.ts @@ -0,0 +1,30 @@ +import { Params } from "@angular/router"; +import { Jsonify } from "type-fest"; + +export class OrganizationInvite { + email: string; + initOrganization: boolean; + orgSsoIdentifier: string; + orgUserHasExistingUser: boolean; + organizationId: string; + organizationName: string; + organizationUserId: string; + token: string; + + static fromJSON(json: Jsonify) { + return Object.assign(new OrganizationInvite(), json); + } + + static fromParams(params: Params): OrganizationInvite { + return Object.assign(new OrganizationInvite(), { + email: params.email, + initOrganization: params.initOrganization?.toLocaleLowerCase() === "true", + orgSsoIdentifier: params.orgSsoIdentifier, + orgUserHasExistingUser: params.orgUserHasExistingUser?.toLocaleLowerCase() === "true", + organizationId: params.organizationId, + organizationName: params.organizationName, + organizationUserId: params.organizationUserId, + token: params.token, + }); + } +} diff --git a/apps/web/src/app/auth/recover-two-factor.component.html b/apps/web/src/app/auth/recover-two-factor.component.html index 11d281b742b3..e3641765800d 100644 --- a/apps/web/src/app/auth/recover-two-factor.component.html +++ b/apps/web/src/app/auth/recover-two-factor.component.html @@ -1,76 +1,40 @@ -
-
-
-

{{ "recoverAccountTwoStep" | i18n }}

-
-
-

- {{ "recoverAccountTwoStepDesc" | i18n }} - {{ "learnMore" | i18n }} -

-
- - -
-
- - -
-
- - -
-
-
- - - {{ "cancel" | i18n }} - -
-
-
-
+ +

+ {{ "recoverAccountTwoStepDesc" | i18n }} + {{ "learnMore" | i18n }} +

+ + {{ "emailAddress" | i18n }} + + + + {{ "masterPass" | i18n }} + + + + {{ "recoveryCodeTitle" | i18n }} + + +
+
+ + + {{ "cancel" | i18n }} +
diff --git a/apps/web/src/app/auth/recover-two-factor.component.ts b/apps/web/src/app/auth/recover-two-factor.component.ts index 145c46c8df5a..4996dbe0a50c 100644 --- a/apps/web/src/app/auth/recover-two-factor.component.ts +++ b/apps/web/src/app/auth/recover-two-factor.component.ts @@ -1,4 +1,5 @@ import { Component } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; import { Router } from "@angular/router"; import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common"; @@ -6,7 +7,6 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { TwoFactorRecoveryRequest } from "@bitwarden/common/auth/models/request/two-factor-recovery.request"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @Component({ @@ -14,10 +14,11 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl templateUrl: "recover-two-factor.component.html", }) export class RecoverTwoFactorComponent { - email: string; - masterPassword: string; - recoveryCode: string; - formPromise: Promise; + protected formGroup = new FormGroup({ + email: new FormControl(null, [Validators.required]), + masterPassword: new FormControl(null, [Validators.required]), + recoveryCode: new FormControl(null, [Validators.required]), + }); constructor( private router: Router, @@ -26,31 +27,32 @@ export class RecoverTwoFactorComponent { private i18nService: I18nService, private cryptoService: CryptoService, private loginStrategyService: LoginStrategyServiceAbstraction, - private logService: LogService, ) {} - async submit() { - try { - const request = new TwoFactorRecoveryRequest(); - request.recoveryCode = this.recoveryCode.replace(/\s/g, "").toLowerCase(); - request.email = this.email.trim().toLowerCase(); - const key = await this.loginStrategyService.makePreloginKey( - this.masterPassword, - request.email, - ); - request.masterPasswordHash = await this.cryptoService.hashMasterKey(this.masterPassword, key); - this.formPromise = this.apiService.postTwoFactorRecover(request); - await this.formPromise; - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t("twoStepRecoverDisabled"), - ); - // 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(["/"]); - } catch (e) { - this.logService.error(e); - } + get email(): string { + return this.formGroup.value.email; } + + get masterPassword(): string { + return this.formGroup.value.masterPassword; + } + + get recoveryCode(): string { + return this.formGroup.value.recoveryCode; + } + + submit = async () => { + const request = new TwoFactorRecoveryRequest(); + request.recoveryCode = this.recoveryCode.replace(/\s/g, "").toLowerCase(); + request.email = this.email.trim().toLowerCase(); + const key = await this.loginStrategyService.makePreloginKey(this.masterPassword, request.email); + request.masterPasswordHash = await this.cryptoService.hashMasterKey(this.masterPassword, key); + await this.apiService.postTwoFactorRecover(request); + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("twoStepRecoverDisabled"), + ); + await this.router.navigate(["/"]); + }; } diff --git a/apps/web/src/app/auth/register-form/register-form.component.ts b/apps/web/src/app/auth/register-form/register-form.component.ts index 6c1b3122c6e1..4532cf14050c 100644 --- a/apps/web/src/app/auth/register-form/register-form.component.ts +++ b/apps/web/src/app/auth/register-form/register-form.component.ts @@ -10,6 +10,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request"; +import { RegisterRequest } from "@bitwarden/common/models/request/register.request"; 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"; @@ -19,6 +20,8 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { DialogService } from "@bitwarden/components"; +import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service"; + @Component({ selector: "app-register-form", templateUrl: "./register-form.component.html", @@ -48,6 +51,7 @@ export class RegisterFormComponent extends BaseRegisterComponent { logService: LogService, auditService: AuditService, dialogService: DialogService, + acceptOrgInviteService: AcceptOrganizationInviteService, ) { super( formValidationErrorService, @@ -65,6 +69,16 @@ export class RegisterFormComponent extends BaseRegisterComponent { auditService, dialogService, ); + super.modifyRegisterRequest = async (request: RegisterRequest) => { + // Org invites are deep linked. Non-existent accounts are redirected to the register page. + // Org user id and token are included here only for validation and two factor purposes. + const orgInvite = await acceptOrgInviteService.getOrganizationInvite(); + if (orgInvite != null) { + request.organizationUserId = orgInvite.organizationUserId; + request.token = orgInvite.token; + } + // Invite is accepted after login (on deep link redirect). + }; } async ngOnInit() { diff --git a/apps/web/src/app/auth/set-password.component.ts b/apps/web/src/app/auth/set-password.component.ts index accde2e9a09e..ccd329dd640d 100644 --- a/apps/web/src/app/auth/set-password.component.ts +++ b/apps/web/src/app/auth/set-password.component.ts @@ -1,9 +1,30 @@ -import { Component } from "@angular/core"; +import { Component, inject } from "@angular/core"; import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { MasterKey, UserKey } from "@bitwarden/common/types/key"; + +import { RouterService } from "../core"; + +import { AcceptOrganizationInviteService } from "./organization-invite/accept-organization.service"; @Component({ selector: "app-set-password", templateUrl: "set-password.component.html", }) -export class SetPasswordComponent extends BaseSetPasswordComponent {} +export class SetPasswordComponent extends BaseSetPasswordComponent { + routerService = inject(RouterService); + acceptOrganizationInviteService = inject(AcceptOrganizationInviteService); + + protected override async onSetPasswordSuccess( + masterKey: MasterKey, + userKey: [UserKey, EncString], + keyPair: [string, EncString], + ): Promise { + await super.onSetPasswordSuccess(masterKey, userKey, keyPair); + // SSO JIT accepts org invites when setting their MP, meaning + // we can clear the deep linked url for accepting it. + await this.routerService.getAndClearLoginRedirectUrl(); + await this.acceptOrganizationInviteService.clearOrganizationInvitation(); + } +} diff --git a/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.html b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.html new file mode 100644 index 000000000000..fd65192beeab --- /dev/null +++ b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.html @@ -0,0 +1,61 @@ + + +
+ + {{ "loading" | i18n }} +
+ + {{ error }} + +

{{ "pickAnAvatarColor" | i18n }}

+
+ + + + + + + + + + +
+
+ + + + +
diff --git a/apps/web/src/app/auth/settings/account/change-avatar.component.ts b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts similarity index 78% rename from apps/web/src/app/auth/settings/account/change-avatar.component.ts rename to apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts index bbcbaf6820ff..6946f8b94bb9 100644 --- a/apps/web/src/app/auth/settings/account/change-avatar.component.ts +++ b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts @@ -1,11 +1,10 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { Component, ElementRef, - EventEmitter, - Input, + Inject, OnDestroy, OnInit, - Output, ViewChild, ViewEncapsulation, } from "@angular/core"; @@ -14,20 +13,20 @@ import { BehaviorSubject, debounceTime, firstValueFrom, Subject, takeUntil } fro import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { ProfileResponse } from "@bitwarden/common/models/response/profile.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { DialogService } from "@bitwarden/components"; + +type ChangeAvatarDialogData = { + profile: ProfileResponse; +}; @Component({ - selector: "app-change-avatar", - templateUrl: "change-avatar.component.html", + templateUrl: "change-avatar-dialog.component.html", encapsulation: ViewEncapsulation.None, }) -export class ChangeAvatarComponent implements OnInit, OnDestroy { - @Input() profile: ProfileResponse; - - @Output() changeColor: EventEmitter = new EventEmitter(); - @Output() onSaved = new EventEmitter(); +export class ChangeAvatarDialogComponent implements OnInit, OnDestroy { + profile: ProfileResponse; @ViewChild("colorPicker") colorPickerElement: ElementRef; @@ -52,11 +51,14 @@ export class ChangeAvatarComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); constructor( + @Inject(DIALOG_DATA) protected data: ChangeAvatarDialogData, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, - private logService: LogService, private avatarService: AvatarService, - ) {} + private dialogRef: DialogRef, + ) { + this.profile = data.profile; + } async ngOnInit() { //localize the default colors @@ -88,20 +90,15 @@ export class ChangeAvatarComponent implements OnInit, OnDestroy { Utils.stringToColor(this.profile.name.toString()); } - async submit() { - try { - if (Utils.validateHexColor(this.currentSelection) || this.currentSelection == null) { - await this.avatarService.setAvatarColor(this.currentSelection); - this.changeColor.emit(this.currentSelection); - this.platformUtilsService.showToast("success", null, this.i18nService.t("avatarUpdated")); - } else { - this.platformUtilsService.showToast("error", null, this.i18nService.t("errorOccurred")); - } - } catch (e) { - this.logService.error(e); + submit = async () => { + if (Utils.validateHexColor(this.currentSelection) || this.currentSelection == null) { + await this.avatarService.setAvatarColor(this.currentSelection); + this.dialogRef.close(); + this.platformUtilsService.showToast("success", null, this.i18nService.t("avatarUpdated")); + } else { this.platformUtilsService.showToast("error", null, this.i18nService.t("errorOccurred")); } - } + }; async ngOnDestroy() { this.destroy$.next(); @@ -131,6 +128,10 @@ export class ChangeAvatarComponent implements OnInit, OnDestroy { } } } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open(ChangeAvatarDialogComponent, config); + } } export class NamedAvatarColor { diff --git a/apps/web/src/app/auth/settings/account/change-avatar.component.html b/apps/web/src/app/auth/settings/account/change-avatar.component.html deleted file mode 100644 index 3a974241d5f8..000000000000 --- a/apps/web/src/app/auth/settings/account/change-avatar.component.html +++ /dev/null @@ -1,84 +0,0 @@ - - - diff --git a/apps/web/src/app/auth/settings/account/profile.component.html b/apps/web/src/app/auth/settings/account/profile.component.html index db6ab2b6588d..4464824c63e2 100644 --- a/apps/web/src/app/auth/settings/account/profile.component.html +++ b/apps/web/src/app/auth/settings/account/profile.component.html @@ -23,6 +23,7 @@ - diff --git a/apps/web/src/app/auth/settings/account/profile.component.ts b/apps/web/src/app/auth/settings/account/profile.component.ts index 64c5687c0b8a..33463b689c99 100644 --- a/apps/web/src/app/auth/settings/account/profile.component.ts +++ b/apps/web/src/app/auth/settings/account/profile.component.ts @@ -1,28 +1,25 @@ -import { ViewChild, ViewContainerRef, Component, OnDestroy, OnInit } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { FormControl, FormGroup } from "@angular/forms"; import { Subject, takeUntil } from "rxjs"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UpdateProfileRequest } from "@bitwarden/common/auth/models/request/update-profile.request"; import { ProfileResponse } from "@bitwarden/common/models/response/profile.response"; 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 { DialogService } from "@bitwarden/components"; -import { ChangeAvatarComponent } from "./change-avatar.component"; +import { ChangeAvatarDialogComponent } from "./change-avatar-dialog.component"; @Component({ selector: "app-profile", templateUrl: "profile.component.html", }) -export class ProfileComponent implements OnInit, OnDestroy { +export class ProfileComponent implements OnInit { loading = true; profile: ProfileResponse; fingerprintMaterial: string; - - @ViewChild("avatarModalTemplate", { read: ViewContainerRef, static: true }) - avatarModalRef: ViewContainerRef; private destroy$ = new Subject(); protected formGroup = new FormGroup({ @@ -35,7 +32,7 @@ export class ProfileComponent implements OnInit, OnDestroy { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private stateService: StateService, - private modalService: ModalService, + private dialogService: DialogService, ) {} async ngOnInit() { @@ -53,24 +50,17 @@ export class ProfileComponent implements OnInit, OnDestroy { }); } + openChangeAvatar = async () => { + ChangeAvatarDialogComponent.open(this.dialogService, { + data: { profile: this.profile }, + }); + }; + async ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } - openChangeAvatar = async () => { - const modalOpened = await this.modalService.openViewRef( - ChangeAvatarComponent, - this.avatarModalRef, - (modal) => { - modal.profile = this.profile; - modal.changeColor.pipe(takeUntil(this.destroy$)).subscribe(() => { - modalOpened[0].close(); - }); - }, - ); - }; - submit = async () => { const request = new UpdateProfileRequest( this.formGroup.get("name").value, diff --git a/apps/web/src/app/auth/settings/security/api-key.component.html b/apps/web/src/app/auth/settings/security/api-key.component.html index 1402a9938819..118b17643ccb 100644 --- a/apps/web/src/app/auth/settings/security/api-key.component.html +++ b/apps/web/src/app/auth/settings/security/api-key.component.html @@ -1,72 +1,42 @@ - +
+ + {{ data.apiKeyTitle | i18n }} +
+

{{ data.apiKeyDescription | i18n }}

+ + + {{ data.apiKeyWarning | i18n }} + +

+ client_id:
+ {{ clientId }} +

+

+ client_secret:
+ {{ clientSecret }} +

+

+ scope:
+ {{ data.scope }} +

+

+ grant_type:
+ {{ data.grantType }} +

+
+
+
+ + +
+
+
diff --git a/apps/web/src/app/auth/settings/security/api-key.component.ts b/apps/web/src/app/auth/settings/security/api-key.component.ts index 9d0055627259..d171bc356175 100644 --- a/apps/web/src/app/auth/settings/security/api-key.component.ts +++ b/apps/web/src/app/auth/settings/security/api-key.component.ts @@ -1,46 +1,58 @@ -import { Component } from "@angular/core"; +import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "@bitwarden/common/auth/models/response/api-key.response"; import { Verification } from "@bitwarden/common/auth/types/verification"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { DialogService } from "@bitwarden/components"; -@Component({ - selector: "app-api-key", - templateUrl: "api-key.component.html", -}) -export class ApiKeyComponent { +export type ApiKeyDialogData = { keyType: string; - isRotation: boolean; - postKey: (entityId: string, request: SecretVerificationRequest) => Promise; + isRotation?: boolean; entityId: string; + postKey: (entityId: string, request: SecretVerificationRequest) => Promise; scope: string; grantType: string; apiKeyTitle: string; apiKeyWarning: string; apiKeyDescription: string; - - masterPassword: Verification; - formPromise: Promise; +}; +@Component({ + selector: "app-api-key", + templateUrl: "api-key.component.html", +}) +export class ApiKeyComponent { clientId: string; clientSecret: string; + formGroup = this.formBuilder.group({ + masterPassword: [null as Verification, [Validators.required]], + }); constructor( + @Inject(DIALOG_DATA) protected data: ApiKeyDialogData, + private formBuilder: FormBuilder, private userVerificationService: UserVerificationService, - private logService: LogService, ) {} - async submit() { - try { - this.formPromise = this.userVerificationService - .buildRequest(this.masterPassword) - .then((request) => this.postKey(this.entityId, request)); - const response = await this.formPromise; - this.clientSecret = response.apiKey; - this.clientId = `${this.keyType}.${this.entityId}`; - } catch (e) { - this.logService.error(e); + submit = async () => { + if (this.formGroup.invalid) { + this.formGroup.markAllAsTouched(); + return; } - } + const response = await this.userVerificationService + .buildRequest(this.formGroup.value.masterPassword) + .then((request) => this.data.postKey(this.data.entityId, request)); + this.clientSecret = response.apiKey; + this.clientId = `${this.data.keyType}.${this.data.entityId}`; + }; + /** + * Strongly typed helper to open a ApiKeyComponent + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + */ + static open = (dialogService: DialogService, config: DialogConfig) => { + return dialogService.open(ApiKeyComponent, config); + }; } diff --git a/apps/web/src/app/auth/settings/security/security-keys.component.ts b/apps/web/src/app/auth/settings/security/security-keys.component.ts index e29417fad74d..8de629dc83e6 100644 --- a/apps/web/src/app/auth/settings/security/security-keys.component.ts +++ b/apps/web/src/app/auth/settings/security/security-keys.component.ts @@ -1,9 +1,9 @@ import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { DialogService } from "@bitwarden/components"; import { ApiKeyComponent } from "./api-key.component"; @@ -22,8 +22,8 @@ export class SecurityKeysComponent implements OnInit { constructor( private userVerificationService: UserVerificationService, private stateService: StateService, - private modalService: ModalService, private apiService: ApiService, + private dialogService: DialogService, ) {} async ngOnInit() { @@ -32,30 +32,34 @@ export class SecurityKeysComponent implements OnInit { async viewUserApiKey() { const entityId = await this.stateService.getUserId(); - await this.modalService.openViewRef(ApiKeyComponent, this.viewUserApiKeyModalRef, (comp) => { - comp.keyType = "user"; - comp.entityId = entityId; - comp.postKey = this.apiService.postUserApiKey.bind(this.apiService); - comp.scope = "api"; - comp.grantType = "client_credentials"; - comp.apiKeyTitle = "apiKey"; - comp.apiKeyWarning = "userApiKeyWarning"; - comp.apiKeyDescription = "userApiKeyDesc"; + await ApiKeyComponent.open(this.dialogService, { + data: { + keyType: "user", + entityId: entityId, + postKey: this.apiService.postUserApiKey.bind(this.apiService), + scope: "api", + grantType: "client_credentials", + apiKeyTitle: "apiKey", + apiKeyWarning: "userApiKeyWarning", + apiKeyDescription: "userApiKeyDesc", + }, }); } async rotateUserApiKey() { const entityId = await this.stateService.getUserId(); - await this.modalService.openViewRef(ApiKeyComponent, this.rotateUserApiKeyModalRef, (comp) => { - comp.keyType = "user"; - comp.isRotation = true; - comp.entityId = entityId; - comp.postKey = this.apiService.postUserRotateApiKey.bind(this.apiService); - comp.scope = "api"; - comp.grantType = "client_credentials"; - comp.apiKeyTitle = "apiKey"; - comp.apiKeyWarning = "userApiKeyWarning"; - comp.apiKeyDescription = "apiKeyRotateDesc"; + await ApiKeyComponent.open(this.dialogService, { + data: { + keyType: "user", + isRotation: true, + entityId: entityId, + postKey: this.apiService.postUserRotateApiKey.bind(this.apiService), + scope: "api", + grantType: "client_credentials", + apiKeyTitle: "apiKey", + apiKeyWarning: "userApiKeyWarning", + apiKeyDescription: "apiKeyRotateDesc", + }, }); } } diff --git a/apps/web/src/app/auth/settings/two-factor-authenticator.component.html b/apps/web/src/app/auth/settings/two-factor-authenticator.component.html index e17714cca79a..a7efaed731e1 100644 --- a/apps/web/src/app/auth/settings/two-factor-authenticator.component.html +++ b/apps/web/src/app/auth/settings/two-factor-authenticator.component.html @@ -1,109 +1,80 @@ - +
+ + {{ "twoStepLogin" | i18n }} + {{ "authenticatorAppTitle" | i18n }} + + + + Authenticator app logo +

{{ "twoStepAuthenticatorDesc" | i18n }}

+

+ 1. {{ "twoStepAuthenticatorDownloadApp" | i18n }} +

+
+ + +

{{ "twoStepLoginProviderEnabled" | i18n }}

+ {{ "twoStepAuthenticatorReaddDesc" | i18n }} +
+ Authenticator app logo +

{{ "twoStepAuthenticatorNeedApp" | i18n }}

+
+ +

{{ "twoStepAuthenticatorAppsRecommended" | i18n }}

+

+ 2. {{ "twoStepAuthenticatorScanCode" | i18n }} +

+
+

+
+ {{ key }} +

+ + + 3. {{ "twoStepAuthenticatorEnterCode" | i18n }} + + + +
+ + + + +
+
diff --git a/apps/web/src/app/auth/settings/two-factor-authenticator.component.ts b/apps/web/src/app/auth/settings/two-factor-authenticator.component.ts index 88b695eb728b..17cdbb595f76 100644 --- a/apps/web/src/app/auth/settings/two-factor-authenticator.component.ts +++ b/apps/web/src/app/auth/settings/two-factor-authenticator.component.ts @@ -1,4 +1,6 @@ -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, EventEmitter, Inject, OnDestroy, OnInit, Output } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; import { firstValueFrom, map } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -38,15 +40,21 @@ export class TwoFactorAuthenticatorComponent extends TwoFactorBaseComponent implements OnInit, OnDestroy { + @Output() onChangeStatus = new EventEmitter(); type = TwoFactorProviderType.Authenticator; key: string; - token: string; formPromise: Promise; override componentName = "app-two-factor-authenticator"; private qrScript: HTMLScriptElement; + protected formGroup = new FormGroup({ + token: new FormControl(null, [Validators.required]), + }); + constructor( + @Inject(DIALOG_DATA) protected data: AuthResponse, + private dialogRef: DialogRef, apiService: ApiService, i18nService: I18nService, userVerificationService: UserVerificationService, @@ -68,8 +76,9 @@ export class TwoFactorAuthenticatorComponent this.qrScript.async = true; } - ngOnInit() { + async ngOnInit() { window.document.body.appendChild(this.qrScript); + await this.auth(this.data); } ngOnDestroy() { @@ -81,17 +90,24 @@ export class TwoFactorAuthenticatorComponent return this.processResponse(authResponse.response); } - submit() { + submit = async () => { if (this.enabled) { - return super.disable(this.formPromise); + await this.disableAuthentication(this.formPromise); + this.onChangeStatus.emit(this.enabled); + this.close(); } else { - return this.enable(); + await this.enable(); + this.onChangeStatus.emit(this.enabled); } + }; + + private async disableAuthentication(promise: Promise) { + return super.disable(promise); } protected async enable() { const request = await this.buildRequestModel(UpdateTwoFactorAuthenticatorRequest); - request.token = this.token; + request.token = this.formGroup.value.token; request.key = this.key; return super.enable(async () => { @@ -102,7 +118,7 @@ export class TwoFactorAuthenticatorComponent } private async processResponse(response: TwoFactorAuthenticatorResponse) { - this.token = null; + this.formGroup.get("token").setValue(null); this.enabled = response.enabled; this.key = response.key; const email = await firstValueFrom( @@ -121,4 +137,15 @@ export class TwoFactorAuthenticatorComponent }); }, 100); } + + close = () => { + this.dialogRef.close(this.enabled); + }; + + static open( + dialogService: DialogService, + config: DialogConfig>, + ) { + return dialogService.open(TwoFactorAuthenticatorComponent, config); + } } diff --git a/apps/web/src/app/auth/settings/two-factor-email.component.html b/apps/web/src/app/auth/settings/two-factor-email.component.html index 93a6b0bb18a5..cf1dba988420 100644 --- a/apps/web/src/app/auth/settings/two-factor-email.component.html +++ b/apps/web/src/app/auth/settings/two-factor-email.component.html @@ -1,101 +1,53 @@ - + + 2. {{ "twoFactorEmailEnterCode" | i18n }} + + + + + + + + + + diff --git a/apps/web/src/app/auth/settings/two-factor-email.component.ts b/apps/web/src/app/auth/settings/two-factor-email.component.ts index 7a2e6de58017..8a5c0292230b 100644 --- a/apps/web/src/app/auth/settings/two-factor-email.component.ts +++ b/apps/web/src/app/auth/settings/two-factor-email.component.ts @@ -1,4 +1,6 @@ -import { Component } from "@angular/core"; +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, EventEmitter, Inject, Output } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { firstValueFrom, map } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -19,18 +21,22 @@ import { TwoFactorBaseComponent } from "./two-factor-base.component"; @Component({ selector: "app-two-factor-email", templateUrl: "two-factor-email.component.html", + outputs: ["onUpdated"], }) export class TwoFactorEmailComponent extends TwoFactorBaseComponent { + @Output() onChangeStatus: EventEmitter = new EventEmitter(); type = TwoFactorProviderType.Email; - email: string; - token: string; sentEmail: string; formPromise: Promise; emailPromise: Promise; - override componentName = "app-two-factor-email"; + formGroup = this.formBuilder.group({ + token: [null], + email: ["", [Validators.email, Validators.required]], + }); constructor( + @Inject(DIALOG_DATA) protected data: AuthResponse, apiService: ApiService, i18nService: I18nService, platformUtilsService: PlatformUtilsService, @@ -38,6 +44,8 @@ export class TwoFactorEmailComponent extends TwoFactorBaseComponent { userVerificationService: UserVerificationService, private accountService: AccountService, dialogService: DialogService, + private formBuilder: FormBuilder, + private dialogRef: DialogRef, ) { super( apiService, @@ -48,32 +56,50 @@ export class TwoFactorEmailComponent extends TwoFactorBaseComponent { dialogService, ); } + get token() { + return this.formGroup.get("token").value; + } + set token(value: string) { + this.formGroup.get("token").setValue(value); + } + get email() { + return this.formGroup.get("email").value; + } + set email(value: string) { + this.formGroup.get("email").setValue(value); + } + + async ngOnInit() { + await this.auth(this.data); + } auth(authResponse: AuthResponse) { super.auth(authResponse); return this.processResponse(authResponse.response); } - submit() { + submit = async () => { if (this.enabled) { - return super.disable(this.formPromise); + await this.disableEmail(); + this.onChangeStatus.emit(false); } else { - return this.enable(); + await this.enable(); + this.onChangeStatus.emit(true); } - } + }; - async sendEmail() { - try { - const request = await this.buildRequestModel(TwoFactorEmailRequest); - request.email = this.email; - this.emailPromise = this.apiService.postTwoFactorEmailSetup(request); - await this.emailPromise; - this.sentEmail = this.email; - } catch (e) { - this.logService.error(e); - } + private disableEmail() { + return super.disable(this.formPromise); } + sendEmail = async () => { + const request = await this.buildRequestModel(TwoFactorEmailRequest); + request.email = this.email; + this.emailPromise = this.apiService.postTwoFactorEmailSetup(request); + await this.emailPromise; + this.sentEmail = this.email; + }; + protected async enable() { const request = await this.buildRequestModel(UpdateTwoFactorEmailRequest); request.email = this.email; @@ -86,6 +112,10 @@ export class TwoFactorEmailComponent extends TwoFactorBaseComponent { }); } + onClose = () => { + this.dialogRef.close(this.enabled); + }; + private async processResponse(response: TwoFactorEmailResponse) { this.token = null; this.email = response.email; @@ -96,4 +126,15 @@ export class TwoFactorEmailComponent extends TwoFactorBaseComponent { ); } } + /** + * Strongly typed helper to open a TwoFactorEmailComponentComponent + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + */ + static open( + dialogService: DialogService, + config: DialogConfig>, + ) { + return dialogService.open(TwoFactorEmailComponent, config); + } } diff --git a/apps/web/src/app/auth/settings/two-factor-setup.component.html b/apps/web/src/app/auth/settings/two-factor-setup.component.html index a20bb3456645..28baf72f885f 100644 --- a/apps/web/src/app/auth/settings/two-factor-setup.component.html +++ b/apps/web/src/app/auth/settings/two-factor-setup.component.html @@ -80,7 +80,6 @@

- diff --git a/apps/web/src/app/auth/settings/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor-setup.component.ts index dc7871baf949..34a7e32089fd 100644 --- a/apps/web/src/app/auth/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor-setup.component.ts @@ -1,3 +1,4 @@ +import { DialogRef } from "@angular/cdk/dialog"; import { Component, OnDestroy, OnInit, Type, ViewChild, ViewContainerRef } from "@angular/core"; import { firstValueFrom, lastValueFrom, Observable, Subject, takeUntil } from "rxjs"; @@ -33,8 +34,6 @@ import { TwoFactorYubiKeyComponent } from "./two-factor-yubikey.component"; templateUrl: "two-factor-setup.component.html", }) export class TwoFactorSetupComponent implements OnInit, OnDestroy { - @ViewChild("authenticatorTemplate", { read: ViewContainerRef, static: true }) - authenticatorModalRef: ViewContainerRef; @ViewChild("yubikeyTemplate", { read: ViewContainerRef, static: true }) yubikeyModalRef: ViewContainerRef; @ViewChild("duoTemplate", { read: ViewContainerRef, static: true }) duoModalRef: ViewContainerRef; @@ -136,12 +135,11 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { if (!result) { return; } - const authComp = await this.openModal( - this.authenticatorModalRef, - TwoFactorAuthenticatorComponent, + const authComp: DialogRef = TwoFactorAuthenticatorComponent.open( + this.dialogService, + { data: result }, ); - await authComp.auth(result); - authComp.onUpdated.pipe(takeUntil(this.destroy$)).subscribe((enabled: boolean) => { + authComp.componentInstance.onChangeStatus.subscribe((enabled: boolean) => { this.updateStatus(enabled, TwoFactorProviderType.Authenticator); }); break; @@ -178,11 +176,14 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { if (!result) { return; } - const emailComp = await this.openModal(this.emailModalRef, TwoFactorEmailComponent); - await emailComp.auth(result); - emailComp.onUpdated.pipe(takeUntil(this.destroy$)).subscribe((enabled: boolean) => { - this.updateStatus(enabled, TwoFactorProviderType.Email); + const authComp: DialogRef = TwoFactorEmailComponent.open(this.dialogService, { + data: result, }); + authComp.componentInstance.onChangeStatus + .pipe(takeUntil(this.destroy$)) + .subscribe((enabled: boolean) => { + this.updateStatus(enabled, TwoFactorProviderType.Email); + }); break; } case TwoFactorProviderType.WebAuthn: { diff --git a/apps/web/src/app/auth/trial-initiation/content/enterprise-content.component.html b/apps/web/src/app/auth/trial-initiation/content/enterprise-content.component.html index 4abb44db4f06..f57fb7a3510b 100644 --- a/apps/web/src/app/auth/trial-initiation/content/enterprise-content.component.html +++ b/apps/web/src/app/auth/trial-initiation/content/enterprise-content.component.html @@ -1,8 +1,8 @@ -

Start your 7-day free trial of Bitwarden

+

Start your 7-day Enterprise free trial

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

    @@ -15,14 +15,14 @@

  • Strengthen employee security practices through centralized administrative control and + >Strengthen company-wide security through centralized administrative control and policies
  • Streamline user onboarding and automate account provisioning with turnkey SSO and SCIM + >Streamline user onboarding and automate account provisioning with flexible SSO and SCIM integrations
  • @@ -35,14 +35,7 @@

  • Save time and increase productivity with autofill and instant device syncing -
  • -
  • - Empower employees to secure their digital life at home, at work, and on the go by offering a - free Families plan to all Enterprise usersGive all Enterprise users the gift of 360º security with a free Families plan
diff --git a/apps/web/src/app/auth/trial-initiation/content/enterprise1-content.component.html b/apps/web/src/app/auth/trial-initiation/content/enterprise1-content.component.html index 120748d4c0df..f57fb7a3510b 100644 --- a/apps/web/src/app/auth/trial-initiation/content/enterprise1-content.component.html +++ b/apps/web/src/app/auth/trial-initiation/content/enterprise1-content.component.html @@ -1,34 +1,44 @@ -

The Password Manager Trusted by Millions

-
-

Everything enterprises need out of a password manager:

+

Start your 7-day Enterprise free trial

+
+

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

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

The Password Manager Trusted by Millions

-
-

Everything enterprises need out of a password manager:

+

Start your 7-day Enterprise free trial

+
+

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

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

Start your 7-day free trial for Teams

-
-
+

Start your 7-day free trial for Teams

+

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

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

Start Your Free Trial Now

-
+

Start your 7-day free trial for Teams

+

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

-
    -
  • Collaborate and share securely
  • -
  • Deploy and manage quickly and easily
  • -
  • Access anywhere on any device
  • -
  • Create your account to get started
  • +
      +
    • + Instantly and securely share credentials with the groups and individuals who need them +
    • +
    • + Migrate to Bitwarden in minutes with comprehensive import options +
    • +
    • + Save time and increase productivity with autofill and instant device syncing +
    • +
    • + Enhance security practices across your team with easy user management +
    - - +
    diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.spec.ts b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.spec.ts index 9879743a589c..a7916ae946d9 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.spec.ts +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.spec.ts @@ -12,15 +12,16 @@ import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PlanType } from "@bitwarden/common/billing/enums"; -import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { RouterService } from "../../core"; import { SharedModule } from "../../shared"; +import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service"; +import { OrganizationInvite } from "../organization-invite/organization-invite"; import { TrialInitiationComponent } from "./trial-initiation.component"; import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component"; @@ -36,12 +37,16 @@ describe("TrialInitiationComponent", () => { let stateServiceMock: MockProxy; let policyApiServiceMock: MockProxy; let policyServiceMock: MockProxy; + let routerServiceMock: MockProxy; + let acceptOrgInviteServiceMock: MockProxy; beforeEach(() => { // only define services directly that we want to mock return values in this component stateServiceMock = mock(); policyApiServiceMock = mock(); policyServiceMock = mock(); + routerServiceMock = mock(); + acceptOrgInviteServiceMock = mock(); // 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 @@ -81,7 +86,11 @@ describe("TrialInitiationComponent", () => { }, { provide: RouterService, - useValue: mock(), + useValue: routerServiceMock, + }, + { + provide: AcceptOrganizationInviteService, + useValue: acceptOrgInviteServiceMock, }, ], schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component) @@ -100,8 +109,8 @@ describe("TrialInitiationComponent", () => { // These tests demonstrate mocking service calls describe("onInit() enforcedPolicyOptions", () => { - it("should not set enforcedPolicyOptions if state service returns no invite", async () => { - stateServiceMock.getOrganizationInvitation.mockReturnValueOnce(null); + it("should not set enforcedPolicyOptions if there isn't an org invite in deep linked url", async () => { + acceptOrgInviteServiceMock.getOrganizationInvite.mockResolvedValueOnce(null); // Need to recreate component with new service mock fixture = TestBed.createComponent(TrialInitiationComponent); component = fixture.componentInstance; @@ -109,37 +118,31 @@ describe("TrialInitiationComponent", () => { expect(component.enforcedPolicyOptions).toBe(undefined); }); - it("should set enforcedPolicyOptions if state service returns an invite", async () => { + it("should set enforcedPolicyOptions if the deep linked url has an org invite", async () => { // Set up service method mocks - stateServiceMock.getOrganizationInvitation.mockReturnValueOnce( - Promise.resolve({ - organizationId: testOrgId, - token: "token", - email: "testEmail", - organizationUserId: "123", - }), - ); + acceptOrgInviteServiceMock.getOrganizationInvite.mockResolvedValueOnce({ + organizationId: testOrgId, + token: "token", + email: "testEmail", + organizationUserId: "123", + } as OrganizationInvite); policyApiServiceMock.getPoliciesByToken.mockReturnValueOnce( - Promise.resolve({ - data: [ - { - id: "345", - organizationId: testOrgId, - type: 1, - data: [ - { - minComplexity: 4, - minLength: 10, - requireLower: null, - requireNumbers: null, - requireSpecial: null, - requireUpper: null, - }, - ], - enabled: true, + Promise.resolve([ + { + id: "345", + organizationId: testOrgId, + type: 1, + data: { + minComplexity: 4, + minLength: 10, + requireLower: null, + requireNumbers: null, + requireSpecial: null, + requireUpper: null, }, - ], - } as ListResponse), + enabled: true, + }, + ] as Policy[]), ); policyServiceMock.masterPasswordPolicyOptions$.mockReturnValue( of({ diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts index 52a4120b1a00..d02b2c9e2ea8 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts @@ -14,13 +14,14 @@ import { ProductType } from "@bitwarden/common/enums"; import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { OrganizationCreatedEvent, SubscriptionProduct, TrialOrganizationType, } from "../../billing/accounts/trial-initiation/trial-billing-step.component"; +import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service"; +import { OrganizationInvite } from "../organization-invite/organization-invite"; import { RouterService } from "./../../core/router.service"; import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component"; @@ -121,12 +122,12 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { protected router: Router, private formBuilder: UntypedFormBuilder, private titleCasePipe: TitleCasePipe, - private stateService: StateService, private logService: LogService, private policyApiService: PolicyApiServiceAbstraction, private policyService: PolicyService, private i18nService: I18nService, private routerService: RouterService, + private acceptOrgInviteService: AcceptOrganizationInviteService, ) {} async ngOnInit(): Promise { @@ -180,30 +181,10 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { : "Password Manager trial from marketing website"; }); - const invite = await this.stateService.getOrganizationInvitation(); - if (invite != null) { - try { - const policies = await this.policyApiService.getPoliciesByToken( - invite.organizationId, - invite.token, - invite.email, - invite.organizationUserId, - ); - if (policies.data != null) { - this.policies = Policy.fromListResponse(policies); - } - } catch (e) { - this.logService.error(e); - } - } - - if (this.policies != null) { - this.policyService - .masterPasswordPolicyOptions$(this.policies) - .pipe(takeUntil(this.destroy$)) - .subscribe((enforcedPasswordPolicyOptions) => { - this.enforcedPolicyOptions = enforcedPasswordPolicyOptions; - }); + // If there's a deep linked org invite, use it to get the password policies + const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite(); + if (orgInvite != null) { + await this.initPasswordPolicies(orgInvite); } this.orgInfoFormGroup.controls.name.valueChanges @@ -304,5 +285,31 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { } } + private async initPasswordPolicies(invite: OrganizationInvite): Promise { + if (invite == null) { + return; + } + + try { + this.policies = await this.policyApiService.getPoliciesByToken( + invite.organizationId, + invite.token, + invite.email, + invite.organizationUserId, + ); + } catch (e) { + this.logService.error(e); + } + + if (this.policies != null) { + this.policyService + .masterPasswordPolicyOptions$(this.policies) + .pipe(takeUntil(this.destroy$)) + .subscribe((enforcedPasswordPolicyOptions) => { + this.enforcedPolicyOptions = enforcedPasswordPolicyOptions; + }); + } + } + protected readonly SubscriptionProduct = SubscriptionProduct; } diff --git a/apps/web/src/app/auth/update-password.component.ts b/apps/web/src/app/auth/update-password.component.ts index b8cfb47db1a4..da62a6812f10 100644 --- a/apps/web/src/app/auth/update-password.component.ts +++ b/apps/web/src/app/auth/update-password.component.ts @@ -1,60 +1,24 @@ -import { Component } from "@angular/core"; -import { Router } from "@angular/router"; +import { Component, inject } from "@angular/core"; import { UpdatePasswordComponent as BaseUpdatePasswordComponent } from "@bitwarden/angular/auth/components/update-password.component"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { 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 { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; -import { DialogService } from "@bitwarden/components"; + +import { RouterService } from "../core"; + +import { AcceptOrganizationInviteService } from "./organization-invite/accept-organization.service"; @Component({ selector: "app-update-password", templateUrl: "update-password.component.html", }) export class UpdatePasswordComponent extends BaseUpdatePasswordComponent { - constructor( - router: Router, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - passwordGenerationService: PasswordGenerationServiceAbstraction, - policyService: PolicyService, - cryptoService: CryptoService, - messagingService: MessagingService, - apiService: ApiService, - logService: LogService, - stateService: StateService, - userVerificationService: UserVerificationService, - dialogService: DialogService, - kdfConfigService: KdfConfigService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - accountService: AccountService, - ) { - super( - router, - i18nService, - platformUtilsService, - passwordGenerationService, - policyService, - cryptoService, - messagingService, - apiService, - stateService, - userVerificationService, - logService, - dialogService, - kdfConfigService, - masterPasswordService, - accountService, - ); + private routerService = inject(RouterService); + private acceptOrganizationInviteService = inject(AcceptOrganizationInviteService); + + override async cancel() { + // clearing the login redirect url so that the user + // does not join the organization if they cancel + await this.routerService.getAndClearLoginRedirectUrl(); + await this.acceptOrganizationInviteService.clearOrganizationInvitation(); + await super.cancel(); } } diff --git a/apps/web/src/app/billing/guards/organization-is-unmanaged.guard.ts b/apps/web/src/app/billing/guards/organization-is-unmanaged.guard.ts new file mode 100644 index 000000000000..a915d8f8a6c5 --- /dev/null +++ b/apps/web/src/app/billing/guards/organization-is-unmanaged.guard.ts @@ -0,0 +1,36 @@ +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn } from "@angular/router"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderStatusType } from "@bitwarden/common/admin-console/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +export const organizationIsUnmanaged: CanActivateFn = async (route: ActivatedRouteSnapshot) => { + const configService = inject(ConfigService); + const organizationService = inject(OrganizationService); + const providerService = inject(ProviderService); + + const consolidatedBillingEnabled = await configService.getFeatureFlag( + FeatureFlag.EnableConsolidatedBilling, + ); + + if (!consolidatedBillingEnabled) { + return true; + } + + const organization = await organizationService.get(route.params.organizationId); + + if (!organization.hasProvider) { + return true; + } + + const provider = await providerService.get(organization.providerId); + + if (!provider) { + return true; + } + + return provider.providerStatus !== ProviderStatusType.Billable; +}; diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 8535f23f8200..9b615f3a6908 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -146,19 +146,17 @@ export class UserSubscriptionComponent implements OnInit { } }; - adjustStorage = (add: boolean) => { - return async () => { - const dialogRef = openAdjustStorageDialog(this.dialogService, { - data: { - storageGbPrice: 4, - add: add, - }, - }); - const result = await lastValueFrom(dialogRef.closed); - if (result === AdjustStorageDialogResult.Adjusted) { - await this.load(); - } - }; + adjustStorage = async (add: boolean) => { + const dialogRef = openAdjustStorageDialog(this.dialogService, { + data: { + storageGbPrice: 4, + add: add, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === AdjustStorageDialogResult.Adjusted) { + await this.load(); + } }; get subscriptionMarkedForCancel() { diff --git a/apps/web/src/app/billing/organizations/download-license.component.html b/apps/web/src/app/billing/organizations/download-license.component.html index 0997462ce92c..33a534bacf75 100644 --- a/apps/web/src/app/billing/organizations/download-license.component.html +++ b/apps/web/src/app/billing/organizations/download-license.component.html @@ -1,39 +1,35 @@ -
    -
    - -

    {{ "downloadLicense" | i18n }}

    -
    -
    -
    - - - - + + + {{ "downloadLicense" | i18n }} + +
    +
    + + {{ "enterInstallationId" | i18n }} + + + + + +
    -
    -
    - - -
    + + + + + + diff --git a/apps/web/src/app/billing/organizations/download-license.component.ts b/apps/web/src/app/billing/organizations/download-license.component.ts index 88a37a28aab0..6b3a93548b4f 100644 --- a/apps/web/src/app/billing/organizations/download-license.component.ts +++ b/apps/web/src/app/billing/organizations/download-license.component.ts @@ -1,50 +1,61 @@ -import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { DialogConfig, DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { DialogService } from "@bitwarden/components"; + +export enum DownloadLicenseDialogResult { + Cancelled = "cancelled", + Downloaded = "downloaded", +} +type DownloadLicenseDialogData = { + /** current organization id */ + organizationId: string; +}; @Component({ - selector: "app-download-license", templateUrl: "download-license.component.html", }) -export class DownloadLicenseComponent { - @Input() organizationId: string; - @Output() onDownloaded = new EventEmitter(); - @Output() onCanceled = new EventEmitter(); - - installationId: string; - formPromise: Promise; - +export class DownloadLicenceDialogComponent { + licenseForm = this.formBuilder.group({ + installationId: ["", [Validators.required]], + }); constructor( + @Inject(DIALOG_DATA) protected data: DownloadLicenseDialogData, + private dialogRef: DialogRef, private fileDownloadService: FileDownloadService, - private logService: LogService, private organizationApiService: OrganizationApiServiceAbstraction, + protected formBuilder: FormBuilder, ) {} - async submit() { - if (this.installationId == null || this.installationId === "") { + submit = async () => { + this.licenseForm.markAllAsTouched(); + const installationId = this.licenseForm.get("installationId").value; + if (installationId == null || installationId === "") { return; } - - try { - this.formPromise = this.organizationApiService.getLicense( - this.organizationId, - this.installationId, - ); - const license = await this.formPromise; - const licenseString = JSON.stringify(license, null, 2); - this.fileDownloadService.download({ - fileName: "bitwarden_organization_license.json", - blobData: licenseString, - }); - this.onDownloaded.emit(); - } catch (e) { - this.logService.error(e); - } - } - - cancel() { - this.onCanceled.emit(); + const license = await this.organizationApiService.getLicense( + this.data.organizationId, + installationId, + ); + const licenseString = JSON.stringify(license, null, 2); + this.fileDownloadService.download({ + fileName: "bitwarden_organization_license.json", + blobData: licenseString, + }); + this.dialogRef.close(DownloadLicenseDialogResult.Downloaded); + }; + /** + * Strongly typed helper to open a DownloadLicenceDialogComponent + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + */ + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open(DownloadLicenceDialogComponent, config); } + cancel = () => { + this.dialogRef.close(DownloadLicenseDialogResult.Cancelled); + }; } diff --git a/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts b/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts index 8ca7226b97db..4af06628754a 100644 --- a/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts @@ -5,6 +5,7 @@ import { canAccessBillingTab } from "@bitwarden/common/admin-console/abstraction import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard"; +import { organizationIsUnmanaged } from "../../billing/guards/organization-is-unmanaged.guard"; import { WebPlatformUtilsService } from "../../core/web-platform-utils.service"; import { PaymentMethodComponent } from "../shared"; @@ -29,7 +30,7 @@ const routes: Routes = [ { path: "payment-method", component: PaymentMethodComponent, - canActivate: [OrganizationPermissionsGuard], + canActivate: [OrganizationPermissionsGuard, organizationIsUnmanaged], data: { titleId: "paymentMethod", organizationPermissions: (org: Organization) => org.canEditPaymentMethods, @@ -38,7 +39,7 @@ const routes: Routes = [ { path: "history", component: OrgBillingHistoryViewComponent, - canActivate: [OrganizationPermissionsGuard], + canActivate: [OrganizationPermissionsGuard, organizationIsUnmanaged], data: { titleId: "billingHistory", organizationPermissions: (org: Organization) => org.canViewBillingHistory, diff --git a/apps/web/src/app/billing/organizations/organization-billing.module.ts b/apps/web/src/app/billing/organizations/organization-billing.module.ts index 490ebafbff05..a95efe32e473 100644 --- a/apps/web/src/app/billing/organizations/organization-billing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing.module.ts @@ -8,7 +8,7 @@ import { AdjustSubscription } from "./adjust-subscription.component"; import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; import { BillingSyncKeyComponent } from "./billing-sync-key.component"; import { ChangePlanComponent } from "./change-plan.component"; -import { DownloadLicenseComponent } from "./download-license.component"; +import { DownloadLicenceDialogComponent } from "./download-license.component"; import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component"; import { OrganizationBillingRoutingModule } from "./organization-billing-routing.module"; import { OrganizationPlansComponent } from "./organization-plans.component"; @@ -32,7 +32,7 @@ import { SubscriptionStatusComponent } from "./subscription-status.component"; BillingSyncApiKeyComponent, BillingSyncKeyComponent, ChangePlanComponent, - DownloadLicenseComponent, + DownloadLicenceDialogComponent, OrganizationSubscriptionCloudComponent, OrganizationSubscriptionSelfhostComponent, OrgBillingHistoryViewComponent, 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 25ac3a7a1554..e11cf602ad27 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 @@ -246,13 +246,6 @@

    {{ "selfHostingTitle" | i18n }}

    {{ (hasBillingSyncToken ? "manageBillingSync" : "setUpBillingSync") | i18n }}
    -
    - -

    {{ "additionalOptions" | i18n }}

    diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index a0db7b5a2004..b6282f1e7b17 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -29,6 +29,7 @@ import { } from "../shared/offboarding-survey.component"; import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component"; +import { DownloadLicenceDialogComponent } from "./download-license.component"; import { ManageBilling } from "./icons/manage-billing.icon"; import { SecretsManagerSubscriptionOptions } from "./sm-adjust-subscription.component"; @@ -354,8 +355,12 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy this.showChangePlan = false; } - downloadLicense() { - this.showDownloadLicense = !this.showDownloadLicense; + async downloadLicense() { + DownloadLicenceDialogComponent.open(this.dialogService, { + data: { + organizationId: this.organizationId, + }, + }); } async manageBillingSync() { diff --git a/apps/web/src/app/billing/shared/payment.component.html b/apps/web/src/app/billing/shared/payment.component.html index db6f4f311b1f..d76de65e507f 100644 --- a/apps/web/src/app/billing/shared/payment.component.html +++ b/apps/web/src/app/billing/shared/payment.component.html @@ -1,163 +1,113 @@ -

    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - -
    -
    - -
    -
    -
    - Visa, MasterCard, Discover, AmEx, JCB, Diners Club, UnionPay -
    -
    - -
    -
    -
    -
    - - + -
    -
    + + + + + {{ "bankAccount" | i18n }} + + + PayPal + + + + + {{ "accountCredit" | i18n }} + +
    - - - - {{ "verifyBankAccountInitialDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }} - -
    -
    - - -
    -
    - - + +
    +
    + +
    +
    +
    + Visa, MasterCard, Discover, AmEx, JCB, Diners Club, UnionPay +
    +
    + +
    +
    +
    +
    + + + + +
    +
    +
    -
    - - + + + + {{ "verifyBankAccountInitialDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }} + +
    + + {{ "routingNumber" | i18n }} + + + + {{ "accountNumber" | i18n }} + + + + {{ "accountHolderName" | i18n }} + + + + + {{ "bankAccountType" | i18n }} + + + + + +
    -
    - - + + +
    +
    + {{ "paypalClickSubmit" | i18n }}
    -
    -
    - -
    -
    - {{ "paypalClickSubmit" | i18n }} -
    -
    - - - {{ "makeSureEnoughCredit" | i18n }} - - + + + + {{ "makeSureEnoughCredit" | i18n }} + + +
    diff --git a/apps/web/src/app/billing/shared/payment.component.ts b/apps/web/src/app/billing/shared/payment.component.ts index 652bed7801d8..95b25a74e4eb 100644 --- a/apps/web/src/app/billing/shared/payment.component.ts +++ b/apps/web/src/app/billing/shared/payment.component.ts @@ -1,4 +1,5 @@ import { Component, Input, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; import { Subject, takeUntil } from "rxjs"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; @@ -17,23 +18,34 @@ import { SharedModule } from "../../shared"; export class PaymentComponent implements OnInit, OnDestroy { @Input() showMethods = true; @Input() showOptions = true; - @Input() method = PaymentMethodType.Card; @Input() hideBank = false; @Input() hidePaypal = false; @Input() hideCredit = false; @Input() trialFlow = false; - private destroy$ = new Subject(); + @Input() + set method(value: PaymentMethodType) { + this._method = value; + this.paymentForm?.controls.method.setValue(value, { emitEvent: false }); + } - bank: any = { - routing_number: null, - account_number: null, - account_holder_name: null, - account_holder_type: "", - currency: "USD", - country: "US", - }; + get method(): PaymentMethodType { + return this._method; + } + private _method: PaymentMethodType = PaymentMethodType.Card; + private destroy$ = new Subject(); + protected paymentForm = new FormGroup({ + method: new FormControl(this.method), + bank: new FormGroup({ + routing_number: new FormControl(null, [Validators.required]), + account_number: new FormControl(null, [Validators.required]), + account_holder_name: new FormControl(null, [Validators.required]), + account_holder_type: new FormControl("", [Validators.required]), + currency: new FormControl("USD"), + country: new FormControl("US"), + }), + }); paymentMethodType = PaymentMethodType; private btScript: HTMLScriptElement; @@ -85,7 +97,6 @@ export class PaymentComponent implements OnInit, OnDestroy { invalid: "is-invalid", }; } - async ngOnInit() { if (!this.showOptions) { this.hidePaypal = this.method !== PaymentMethodType.PayPal; @@ -97,6 +108,13 @@ export class PaymentComponent implements OnInit, OnDestroy { if (!this.hidePaypal) { window.document.head.appendChild(this.btScript); } + this.paymentForm + .get("method") + .valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe((v) => { + this.method = v; + this.changeMethod(); + }); } ngOnDestroy() { @@ -140,7 +158,6 @@ export class PaymentComponent implements OnInit, OnDestroy { changeMethod() { this.btInstance = null; - if (this.method === PaymentMethodType.PayPal) { window.setTimeout(() => { (window as any).braintree.dropin.create( @@ -209,15 +226,17 @@ export class PaymentComponent implements OnInit, OnDestroy { } }); } else { - this.stripe.createToken("bank_account", this.bank).then((result: any) => { - if (result.error) { - reject(result.error.message); - } else if (result.token && result.token.id != null) { - resolve([result.token.id, this.method]); - } else { - reject(); - } - }); + this.stripe + .createToken("bank_account", this.paymentForm.get("bank").value) + .then((result: any) => { + if (result.error) { + reject(result.error.message); + } else if (result.token && result.token.id != null) { + resolve([result.token.id, this.method]); + } else { + reject(); + } + }); } } }); diff --git a/apps/web/src/app/common/base.accept.component.ts b/apps/web/src/app/common/base.accept.component.ts index cad1d90088c9..7c35751aea65 100644 --- a/apps/web/src/app/common/base.accept.component.ts +++ b/apps/web/src/app/common/base.accept.component.ts @@ -1,11 +1,12 @@ import { Directive, OnInit } from "@angular/core"; import { ActivatedRoute, Params, Router } from "@angular/router"; -import { Subject } from "rxjs"; +import { Subject, firstValueFrom } from "rxjs"; import { first, switchMap, takeUntil } from "rxjs/operators"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @Directive() export abstract class BaseAcceptComponent implements OnInit { @@ -25,7 +26,7 @@ export abstract class BaseAcceptComponent implements OnInit { protected platformUtilService: PlatformUtilsService, protected i18nService: I18nService, protected route: ActivatedRoute, - protected stateService: StateService, + protected authService: AuthService, ) {} abstract authedHandler(qParams: Params): Promise; @@ -41,10 +42,10 @@ export abstract class BaseAcceptComponent implements OnInit { ); let errorMessage: string = null; if (!error) { - this.authed = await this.stateService.getIsAuthenticated(); this.email = qParams.email; - if (this.authed) { + const status = await firstValueFrom(this.authService.activeAccountStatus$); + if (status !== AuthenticationStatus.LoggedOut) { try { await this.authedHandler(qParams); } catch (e) { diff --git a/apps/web/src/app/core/router.service.ts b/apps/web/src/app/core/router.service.ts index caebb227337c..2944732aee64 100644 --- a/apps/web/src/app/core/router.service.ts +++ b/apps/web/src/app/core/router.service.ts @@ -12,6 +12,14 @@ import { GlobalState, } from "@bitwarden/common/platform/state"; +/** + * Data properties acceptable for use in route objects (see usage in oss-routing.module.ts for example) + */ +export interface DataProperties { + titleId?: string; // sets the title of the current HTML document (shows in browser tab) + doNotSaveUrl?: boolean; // choose to not keep track of the previous URL in memory +} + const DEEP_LINK_REDIRECT_URL = new KeyDefinition(ROUTER_DISK, "deepLinkRedirectUrl", { deserializer: (value: string) => value, }); @@ -92,7 +100,7 @@ export class RouterService { /** * Fetch and clear persisted LoginRedirectUrl if present in state */ - async getAndClearLoginRedirectUrl(): Promise | undefined { + async getAndClearLoginRedirectUrl(): Promise { const persistedPreLoginUrl = await firstValueFrom(this.deepLinkRedirectUrlState.state$); if (!Utils.isNullOrEmpty(persistedPreLoginUrl)) { diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 1ce8d4d2278c..757b8220f3ad 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -10,7 +10,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { IconModule, LayoutComponent, NavigationModule } from "@bitwarden/components"; import { PaymentMethodWarningsModule } from "../billing/shared"; diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 93d68d31b38a..77e511722699 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -7,7 +7,9 @@ import { redirectGuard, tdeDecryptionRequiredGuard, UnauthGuard, + unauthGuardFn, } from "@bitwarden/angular/auth/guards"; +import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/auth/angular"; import { flagEnabled, Flags } from "../utils/flags"; @@ -17,7 +19,6 @@ import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizatio import { VerifyRecoverDeleteProviderComponent } from "./admin-console/providers/verify-recover-delete-provider.component"; import { CreateOrganizationComponent } from "./admin-console/settings/create-organization.component"; import { SponsoredFamiliesComponent } from "./admin-console/settings/sponsored-families.component"; -import { AcceptOrganizationComponent } from "./auth/accept-organization.component"; import { deepLinkGuard } from "./auth/guards/deep-link.guard"; import { HintComponent } from "./auth/hint.component"; import { LockComponent } from "./auth/lock.component"; @@ -25,6 +26,7 @@ import { LoginDecryptionOptionsComponent } from "./auth/login/login-decryption-o import { LoginViaAuthRequestComponent } from "./auth/login/login-via-auth-request.component"; import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component"; import { LoginComponent } from "./auth/login/login.component"; +import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component"; import { RecoverDeleteComponent } from "./auth/recover-delete.component"; import { RecoverTwoFactorComponent } from "./auth/recover-two-factor.component"; import { RemovePasswordComponent } from "./auth/remove-password.component"; @@ -40,6 +42,8 @@ import { UpdatePasswordComponent } from "./auth/update-password.component"; import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component"; import { VerifyEmailTokenComponent } from "./auth/verify-email-token.component"; import { VerifyRecoverDeleteComponent } from "./auth/verify-recover-delete.component"; +import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component"; +import { DataProperties } from "./core"; import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; import { UserLayoutComponent } from "./layouts/user-layout.component"; import { DomainRulesComponent } from "./settings/domain-rules.component"; @@ -54,7 +58,7 @@ const routes: Routes = [ { path: "", component: FrontendLayoutComponent, - data: { doNotSaveUrl: true }, + data: { doNotSaveUrl: true } satisfies DataProperties, children: [ { path: "", @@ -66,17 +70,17 @@ const routes: Routes = [ { path: "login-with-device", component: LoginViaAuthRequestComponent, - data: { titleId: "loginWithDevice" }, + data: { titleId: "loginWithDevice" } satisfies DataProperties, }, { path: "login-with-passkey", component: LoginViaWebAuthnComponent, - data: { titleId: "loginWithPasskey" }, + data: { titleId: "loginWithPasskey" } satisfies DataProperties, }, { path: "admin-approval-requested", component: LoginViaAuthRequestComponent, - data: { titleId: "adminApprovalRequested" }, + data: { titleId: "adminApprovalRequested" } satisfies DataProperties, }, { path: "2fa", component: TwoFactorComponent, canActivate: [UnauthGuard] }, { @@ -88,7 +92,7 @@ const routes: Routes = [ path: "register", component: TrialInitiationComponent, canActivate: [UnauthGuard], - data: { titleId: "createAccount" }, + data: { titleId: "createAccount" } satisfies DataProperties, }, { path: "trial", @@ -99,18 +103,18 @@ const routes: Routes = [ path: "sso", component: SsoComponent, canActivate: [UnauthGuard], - data: { titleId: "enterpriseSingleSignOn" }, + data: { titleId: "enterpriseSingleSignOn" } satisfies DataProperties, }, { path: "set-password", component: SetPasswordComponent, - data: { titleId: "setMasterPassword" }, + data: { titleId: "setMasterPassword" } satisfies DataProperties, }, { path: "hint", component: HintComponent, canActivate: [UnauthGuard], - data: { titleId: "passwordHint" }, + data: { titleId: "passwordHint" } satisfies DataProperties, }, { path: "lock", @@ -120,43 +124,28 @@ const routes: Routes = [ { path: "verify-email", component: VerifyEmailTokenComponent }, { path: "accept-organization", - component: AcceptOrganizationComponent, - canActivate: [deepLinkGuard()], - data: { titleId: "joinOrganization", doNotSaveUrl: false }, - }, - { - path: "accept-emergency", canActivate: [deepLinkGuard()], - data: { titleId: "acceptEmergency", doNotSaveUrl: false }, - loadComponent: () => - import("./auth/emergency-access/accept/accept-emergency.component").then( - (mod) => mod.AcceptEmergencyComponent, - ), + component: AcceptOrganizationComponent, + data: { titleId: "joinOrganization", doNotSaveUrl: false } satisfies DataProperties, }, { path: "accept-families-for-enterprise", component: AcceptFamilySponsorshipComponent, canActivate: [deepLinkGuard()], - data: { titleId: "acceptFamilySponsorship", doNotSaveUrl: false }, + data: { titleId: "acceptFamilySponsorship", doNotSaveUrl: false } satisfies DataProperties, }, { path: "recover", pathMatch: "full", redirectTo: "recover-2fa" }, - { - path: "recover-2fa", - component: RecoverTwoFactorComponent, - canActivate: [UnauthGuard], - data: { titleId: "recoverAccountTwoStep" }, - }, { path: "recover-delete", component: RecoverDeleteComponent, canActivate: [UnauthGuard], - data: { titleId: "deleteAccount" }, + data: { titleId: "deleteAccount" } satisfies DataProperties, }, { path: "verify-recover-delete", component: VerifyRecoverDeleteComponent, canActivate: [UnauthGuard], - data: { titleId: "deleteAccount" }, + data: { titleId: "deleteAccount" } satisfies DataProperties, }, { path: "verify-recover-delete-org", @@ -168,30 +157,30 @@ const routes: Routes = [ path: "verify-recover-delete-provider", component: VerifyRecoverDeleteProviderComponent, canActivate: [UnauthGuard], - data: { titleId: "deleteAccount" }, + data: { titleId: "deleteAccount" } satisfies DataProperties, }, { path: "send/:sendId/:key", component: AccessComponent, - data: { title: "Bitwarden Send" }, + data: { titleId: "Bitwarden Send" } satisfies DataProperties, }, { path: "update-temp-password", component: UpdateTempPasswordComponent, canActivate: [AuthGuard], - data: { titleId: "updateTempPassword" }, + data: { titleId: "updateTempPassword" } satisfies DataProperties, }, { path: "update-password", component: UpdatePasswordComponent, canActivate: [AuthGuard], - data: { titleId: "updatePassword" }, + data: { titleId: "updatePassword" } satisfies DataProperties, }, { path: "remove-password", component: RemovePasswordComponent, canActivate: [AuthGuard], - data: { titleId: "removeMasterPassword" }, + data: { titleId: "removeMasterPassword" } satisfies DataProperties, }, { path: "migrate-legacy-encryption", @@ -202,6 +191,54 @@ const routes: Routes = [ }, ], }, + { + path: "", + component: AnonLayoutWrapperComponent, + children: [ + { + path: "recover-2fa", + canActivate: [unauthGuardFn()], + children: [ + { + path: "", + component: RecoverTwoFactorComponent, + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + data: { + pageTitle: "recoverAccountTwoStep", + titleId: "recoverAccountTwoStep", + } satisfies DataProperties & AnonLayoutWrapperData, + }, + { + path: "accept-emergency", + canActivate: [deepLinkGuard()], + children: [ + { + path: "", + data: { + pageTitle: "emergencyAccess", + titleId: "acceptEmergency", + doNotSaveUrl: false, + } satisfies DataProperties & AnonLayoutWrapperData, + loadComponent: () => + import("./auth/emergency-access/accept/accept-emergency.component").then( + (mod) => mod.AcceptEmergencyComponent, + ), + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + ], + }, { path: "", component: UserLayoutComponent, @@ -211,21 +248,29 @@ const routes: Routes = [ path: "vault", loadChildren: () => VaultModule, }, - { path: "sends", component: SendComponent, data: { titleId: "send" } }, + { + path: "sends", + component: SendComponent, + data: { titleId: "send" } satisfies DataProperties, + }, { path: "create-organization", component: CreateOrganizationComponent, - data: { titleId: "newOrganization" }, + data: { titleId: "newOrganization" } satisfies DataProperties, }, { path: "settings", children: [ { path: "", pathMatch: "full", redirectTo: "account" }, - { path: "account", component: AccountComponent, data: { titleId: "myAccount" } }, + { + path: "account", + component: AccountComponent, + data: { titleId: "myAccount" } satisfies DataProperties, + }, { path: "preferences", component: PreferencesComponent, - data: { titleId: "preferences" }, + data: { titleId: "preferences" } satisfies DataProperties, }, { path: "security", @@ -234,7 +279,7 @@ const routes: Routes = [ { path: "domain-rules", component: DomainRulesComponent, - data: { titleId: "domainRules" }, + data: { titleId: "domainRules" } satisfies DataProperties, }, { path: "subscription", @@ -249,19 +294,19 @@ const routes: Routes = [ { path: "", component: EmergencyAccessComponent, - data: { titleId: "emergencyAccess" }, + data: { titleId: "emergencyAccess" } satisfies DataProperties, }, { path: ":id", component: EmergencyAccessViewComponent, - data: { titleId: "emergencyAccess" }, + data: { titleId: "emergencyAccess" } satisfies DataProperties, }, ], }, { path: "sponsored-families", component: SponsoredFamiliesComponent, - data: { titleId: "sponsoredFamilies" }, + data: { titleId: "sponsoredFamilies" } satisfies DataProperties, }, ], }, @@ -276,7 +321,7 @@ const routes: Routes = [ import("./tools/import/import-web.component").then((mod) => mod.ImportWebComponent), data: { titleId: "importData", - }, + } satisfies DataProperties, }, { path: "export", @@ -286,7 +331,7 @@ const routes: Routes = [ { path: "generator", component: GeneratorComponent, - data: { titleId: "generator" }, + data: { titleId: "generator" } satisfies DataProperties, }, ], }, diff --git a/apps/web/src/app/settings/preferences.component.html b/apps/web/src/app/settings/preferences.component.html index 828c251989fb..984ae7536a54 100644 --- a/apps/web/src/app/settings/preferences.component.html +++ b/apps/web/src/app/settings/preferences.component.html @@ -1,106 +1,77 @@ -

    {{ "preferencesDesc" | i18n }}

    -
    -
    -
    - - - {{ - "vaultTimeoutPolicyWithActionInEffect" - | i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n) - }} - - - {{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }} - - - {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} - - - - -
    -
    +

    {{ "preferencesDesc" | i18n }}

    + + + + {{ + "vaultTimeoutPolicyWithActionInEffect" + | i18n: policy.timeout.hours : policy.timeout.minutes : (policy.action | i18n) + }} + + + {{ "vaultTimeoutPolicyInEffect" | i18n: policy.timeout.hours : policy.timeout.minutes }} + + + {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} + + + + -
    - -
    + {{ "vaultTimeoutAction" | i18n }} + - - -
    -
    {{ "lock" | i18n }} + {{ "vaultTimeoutActionLockDesc" | i18n }} + + - - -
    -
    + {{ "logOut" | i18n }} + {{ "vaultTimeoutActionLogOutDesc" | i18n }} + +
    -
    -
    -
    -
    - - - - -
    - - {{ "languageDesc" | i18n }} -
    -
    -
    -
    -
    - - + + {{ "language" | i18n }} + + + + + + + + {{ "languageDesc" | i18n }} + + + + {{ "enableFavicon" | i18n }} -
    - {{ "faviconDesc" | i18n }} -
    -
    -
    -
    - - - {{ "themeDesc" | i18n }} -
    -
    -
    - + + {{ "faviconDesc" | i18n }} + + + {{ "theme" | i18n }} + + + + {{ "themeDesc" | i18n }} + +
    diff --git a/apps/web/src/app/settings/preferences.component.ts b/apps/web/src/app/settings/preferences.component.ts index a6443b453efc..1092a31d5c27 100644 --- a/apps/web/src/app/settings/preferences.component.ts +++ b/apps/web/src/app/settings/preferences.component.ts @@ -158,7 +158,7 @@ export class PreferencesComponent implements OnInit { this.form.setValue(initialFormValues, { emitEvent: false }); } - async submit() { + submit = async () => { if (!this.form.controls.vaultTimeout.valid) { this.platformUtilsService.showToast( "error", @@ -188,7 +188,7 @@ export class PreferencesComponent implements OnInit { this.i18nService.t("preferencesUpdated"), ); } - } + }; ngOnDestroy() { this.destroy$.next(); diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index cf273ee68252..b8cfbd340131 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -20,7 +20,6 @@ import { ProvidersComponent } from "../admin-console/providers/providers.compone import { VerifyRecoverDeleteProviderComponent } from "../admin-console/providers/verify-recover-delete-provider.component"; import { SponsoredFamiliesComponent } from "../admin-console/settings/sponsored-families.component"; import { SponsoringOrgRowComponent } from "../admin-console/settings/sponsoring-org-row.component"; -import { AcceptOrganizationComponent } from "../auth/accept-organization.component"; import { HintComponent } from "../auth/hint.component"; import { LockComponent } from "../auth/lock.component"; import { RecoverDeleteComponent } from "../auth/recover-delete.component"; @@ -29,7 +28,7 @@ import { RegisterFormModule } from "../auth/register-form/register-form.module"; import { RemovePasswordComponent } from "../auth/remove-password.component"; import { SetPasswordComponent } from "../auth/set-password.component"; import { AccountComponent } from "../auth/settings/account/account.component"; -import { ChangeAvatarComponent } from "../auth/settings/account/change-avatar.component"; +import { ChangeAvatarDialogComponent } from "../auth/settings/account/change-avatar-dialog.component"; import { ChangeEmailComponent } from "../auth/settings/account/change-email.component"; import { DangerZoneComponent } from "../auth/settings/account/danger-zone.component"; import { DeauthorizeSessionsComponent } from "../auth/settings/account/deauthorize-sessions.component"; @@ -120,7 +119,6 @@ import { SharedModule } from "./shared.module"; ], declarations: [ AcceptFamilySponsorshipComponent, - AcceptOrganizationComponent, AccountComponent, AddEditComponent, AddEditCustomFieldsComponent, @@ -158,7 +156,7 @@ import { SharedModule } from "./shared.module"; PreferencesComponent, PremiumBadgeComponent, ProfileComponent, - ChangeAvatarComponent, + ChangeAvatarDialogComponent, ProvidersComponent, PurgeVaultComponent, RecoverDeleteComponent, @@ -193,7 +191,6 @@ import { SharedModule } from "./shared.module"; exports: [ UserVerificationModule, PremiumBadgeComponent, - AcceptOrganizationComponent, AccountComponent, AddEditComponent, AddEditCustomFieldsComponent, @@ -233,7 +230,7 @@ import { SharedModule } from "./shared.module"; PreferencesComponent, PremiumBadgeComponent, ProfileComponent, - ChangeAvatarComponent, + ChangeAvatarDialogComponent, ProvidersComponent, PurgeVaultComponent, RecoverDeleteComponent, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts index 8dd63e62ddb5..5a138c3147bb 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts @@ -13,7 +13,7 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; 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 { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { DialogService } from "@bitwarden/components"; import { OrganizationUserResetPasswordService } from "../../../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index ca04b3aa51f0..ae3a0657788b 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -45,9 +45,9 @@ 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 { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data"; 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 514cb8150d1e..dfdce5c818e6 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -48,10 +48,10 @@ 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 { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; diff --git a/apps/web/src/app/vault/settings/purge-vault.component.ts b/apps/web/src/app/vault/settings/purge-vault.component.ts index 869cbaab1b4a..9a677af7b5de 100644 --- a/apps/web/src/app/vault/settings/purge-vault.component.ts +++ b/apps/web/src/app/vault/settings/purge-vault.component.ts @@ -8,7 +8,7 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use import { Verification } from "@bitwarden/common/auth/types/verification"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { DialogService } from "@bitwarden/components"; export interface PurgeVaultDialogData { diff --git a/apps/web/src/images/register-layout/vault-signup-badges.png b/apps/web/src/images/register-layout/vault-signup-badges.png index 7a80ffaebb9a..c8a7ae2f48f8 100644 Binary files a/apps/web/src/images/register-layout/vault-signup-badges.png and b/apps/web/src/images/register-layout/vault-signup-badges.png differ diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 06a957e88333..0bbcde1f4933 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Toegangsvlak" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Uitgeteken" }, diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index e5f0fbe933b7..92d90de8ded7 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "تم تسجيل الخروج" }, diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index 963ea19cc745..71f7f573d658 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Müraciət səviyyəsi" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Çıxış edildi" }, diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index 8ec953ead5dd..3127daed9424 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Вы выйшлі" }, diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index 17f37b277754..a1be0b8fdf12 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Ниво на достъп" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Бяхте отписани" }, diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index edf5b44ba56f..525586cc863b 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index b4a453bafe7f..84dc065cec5c 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index a759371fd4ee..24f5f561e56a 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Nivell d'accés" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Sessió tancada" }, diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 63be12e4052e..7b08b7ef2f20 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Úroveň přístupu" }, + "accessing": { + "message": "Přistupování" + }, "loggedOut": { "message": "Odhlášení" }, diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index 2b5bdb554730..a9b33e0e14f7 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 9f689aa000dd..f4031107f1ce 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Adgangsniveau" }, + "accessing": { + "message": "Tilgår" + }, "loggedOut": { "message": "Logget ud" }, diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 008e0261d884..ecb3bf972916 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Zugriffsebene" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Ausgeloggt" }, diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index deed63f8e1a1..42cc702f2715 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Αποσυνδεθήκατε" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index c8f193185999..d7a21ad6d6a1 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -587,6 +587,9 @@ "loggedOut": { "message": "Logged out" }, + "loggedOutDesc": { + "message": "You have been logged out of your account." + }, "loginExpired": { "message": "Your login session has expired." }, @@ -1050,6 +1053,12 @@ "copyUuid": { "message": "Copy UUID" }, + "errorRefreshingAccessToken":{ + "message": "Access Token Refresh Error" + }, + "errorRefreshingAccessTokenDesc":{ + "message": "No refresh token or API keys found. Please try logging out and logging back in." + }, "warning": { "message": "Warning" }, @@ -5586,6 +5595,39 @@ "rotateBillingSyncTokenTitle": { "message": "Rotating the billing sync token will invalidate the previous token." }, + "selfHostedServer": { + "message": "self-hosted" + }, + "customEnvironment": { + "message": "Custom environment" + }, + "selfHostedBaseUrlHint": { + "message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com" + }, + "selfHostedCustomEnvHeader" :{ + "message": "For advanced configuration, you can specify the base URL of each service independently." + }, + "selfHostedEnvFormInvalid" :{ + "message": "You must add either the base Server URL or at least one custom environment." + }, + "apiUrl": { + "message": "API server URL" + }, + "webVaultUrl": { + "message": "Web vault server URL" + }, + "identityUrl": { + "message": "Identity server URL" + }, + "notificationsUrl": { + "message": "Notifications server URL" + }, + "iconsUrl": { + "message": "Icons server URL" + }, + "environmentSaved": { + "message": "Environment URLs saved" + }, "selfHostingTitle": { "message": "Self-hosting" }, @@ -8297,5 +8339,20 @@ }, "allLoginRequestsApproved": { "message": "All login requests approved" + }, + "payPal": { + "message": "PayPal" + }, + "bitcoin": { + "message": "Bitcoin" + }, + "updatedTaxInformation": { + "message": "Updated tax information" + }, + "unverified": { + "message": "Unverified" + }, + "verified": { + "message": "Verified" } } diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index 77e378f8bed1..93f1832301ad 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index f71834e6d81c..f1fa51236e53 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 8c518ab273e4..8e0b58c3f1cf 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Adiaŭita" }, diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 69d7a5ddbbe3..8eed13491ee4 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Nivel de acceso" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Sesión terminada" }, diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index a601c437b159..27f15dc47456 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Välja logitud" }, diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index 0e8fc63c3aac..3bb6a81998ce 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Saioa itxita" }, diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index d12af42e3b6c..42e1ff68cc58 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "خارج شد" }, diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index 53b9723509a1..3b0e8b4b8445 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Käyttöoikeustaso" }, + "accessing": { + "message": "Avataan" + }, "loggedOut": { "message": "Kirjauduttu ulos" }, @@ -8290,9 +8293,9 @@ "message": "Pysy haavoittuvuuksien edellä tehostamalla valvontaa päivittämällä maksulliseen tilaukseeen." }, "approveAllRequests": { - "message": "Approve all requests" + "message": "Hyväksy kaikki pyynnöt" }, "allLoginRequestsApproved": { - "message": "All login requests approved" + "message": "Kaikki kirjautumispyynnöt hyväksyttiin" } } diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index 96d7398e4e39..1a9f2d67823e 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Naka-log out" }, diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index a915955960bd..189571f70d0a 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Niveau d'accès" }, + "accessing": { + "message": "Accès en cours" + }, "loggedOut": { "message": "Déconnecté" }, diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index 59167b94d9d5..99c15045cc9f 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 3ab0bf2dc145..f84612ef46be 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "בוצעה יציאה" }, diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index 04d2d691c3af..65a353768a52 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index e88ba8016f30..7f939ba90ca2 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Odjavljen/a" }, diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index af4e018159a7..9226307792f6 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Hozzáférési szint" }, + "accessing": { + "message": "Elérés" + }, "loggedOut": { "message": "Megtörtént a kijelentkezés." }, diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index d1d81cc3382c..a767d3bcb0ab 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Keluar" }, diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index 02d01034ef9b..d78693360038 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Livello di accesso" }, + "accessing": { + "message": "Accedendo a" + }, "loggedOut": { "message": "Uscito" }, diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index f4bd38e65541..b8016fb1f808 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "アクセスレベル" }, + "accessing": { + "message": "アクセス中" + }, "loggedOut": { "message": "ログアウトしました" }, diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index 6c0a32b66727..c1cb6d9a96a4 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "გამოსვლა" }, diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index 59167b94d9d5..99c15045cc9f 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index ef3d2f4d180b..3d369d33fc7c 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "ಲಾಗ್ ಔಟ್" }, diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index df54ad4d32ea..6a7b32983b6e 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "접근 권한" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "로그아웃됨" }, diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index febb28d05654..61edf711b4d9 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Piekļuves līmenis" }, + "accessing": { + "message": "Piekļūst" + }, "loggedOut": { "message": "Atteicies" }, @@ -833,7 +836,7 @@ "message": "Nederīga galvenā parole" }, "invalidFilePassword": { - "message": "Nederīga datnes parole, lūgums izmantot to paroli, kas tika ievadīta izdošanas datnes izveidošanas brīdī." + "message": "Nederīga datnes parole, lūgums izmantot to paroli, kas tika ievadīta izgūšanas datnes izveidošanas brīdī." }, "lockNow": { "message": "Aizslēgt" @@ -1057,10 +1060,10 @@ "message": "Apstiprināt noslēpumu izgūšanu" }, "exportWarningDesc": { - "message": "Šī izguve satur glabātavas datus nešifrētā veidā. Izdoto datni nevajadzētu glabāt vai sūtīt nedrošos veidos (piemēram, e-pastā). Izdzēst to uzreiz pēc izmantošanas." + "message": "Šī izguve satur glabātavas datus nešifrētā veidā. Izgūto datni nevajadzētu glabāt vai sūtīt nedrošos veidos (piemēram, e-pastā). Tā ir jāizdzēš uzreiz pēc izmantošanas." }, "exportSecretsWarningDesc": { - "message": "Šī izguve satur noslēpumu datus nešifrētā veidā. Izdoto datni nevajadzētu glabāt vai sūtīt nedrošos veidos (piemēram, e-pastā). Izdzēst to uzreiz pēc izmantošanas." + "message": "Šī izguve satur noslēpumu datus nešifrētā veidā. Izgūto datni nevajadzētu glabāt vai sūtīt nedrošos veidos (piemēram, e-pastā). Tā ir jāizdzēš uzreiz pēc izmantošanas." }, "encExportKeyWarningDesc": { "message": "Šī izguve šifrē datus ar konta šifrēšanas atslēgu. Ja tā jebkad tiks mainīta, izvadi vajadzētu veikt vēlreiz, jo vairs nebūs iespējams atšifrēt šo datni." @@ -1084,10 +1087,10 @@ "message": "Datnes veids" }, "fileEncryptedExportWarningDesc": { - "message": "Šī datņu izdošana būs aizsargāta ar paroli, un būs nepieciešama datnes parole, lai to atšifrētu." + "message": "Šī datņu izgūšana būs aizsargāta ar paroli, un būs nepieciešama datnes parole, lai to atšifrētu." }, "exportPasswordDescription": { - "message": "Šī parole tiks izmantota, lai izdotu un ievietotu šo datni" + "message": "Šī parole tiks izmantota, lai izgūtu un ievietotu šo datni" }, "confirmMasterPassword": { "message": "Apstiprināt galveno paroli" @@ -1102,13 +1105,13 @@ "message": "Apstiprināt datnes paroli" }, "accountRestrictedOptionDescription": { - "message": "Izmantot konta šifrēšanas atslēgu, kas iegūta no lietotājvārda un galvenās paroles, lai šifrētu izdošanu un atļautu ievietošanu tikai pašreizējā Bitwarden kontā." + "message": "Jāizmanto konta šifrēšanas atslēga, kas iegūta no lietotājvārda un galvenās paroles, lai šifrētu izguvi un atļautu ievietošanu tikai pašreizējā Bitwarden kontā." }, "passwordProtectedOptionDescription": { - "message": "Uzstādīt paroli, lai šifrētu izdošanu un tad to ievietotu jebkurā Bitwarden kontā, izmantojot atšifrēšanas paroli." + "message": "Uzstādīt paroli, lai šifrētu izguvi un tad to ievietotu jebkurā Bitwarden kontā, izmantojot atšifrēšanas paroli." }, "exportTypeHeading": { - "message": "Izdošanas veids" + "message": "Izgūšanas veids" }, "accountRestricted": { "message": "Konts ir ierobežots" @@ -5681,10 +5684,10 @@ "message": "Sesijai iestājās noildze. Lūgums mēģināt pieteikties vēlreiz." }, "exportingPersonalVaultTitle": { - "message": "Izdod personīgo glabātavu" + "message": "Izgūst personīgo glabātavu" }, "exportingOrganizationVaultTitle": { - "message": "Izdod apvienības glabātavu" + "message": "Izgūst apvienības glabātavu" }, "exportingIndividualVaultDescription": { "message": "Tiks izgūti tikai atsevišķi glabātavas vienumi, kas ir saistīti ar $EMAIL$. Apvienības glabātavas vienumi netiks iekļauti. Tiks izgūta tikai glabātavas vienumu informācija, un saistītie pielikumi netiks iekļauti.", @@ -5696,7 +5699,7 @@ } }, "exportingOrganizationVaultDesc": { - "message": "Tiks izdota tikai apvienības glabātava, kas ir saistīta ar $ORGANIZATION$. Atsevišķu glabātavu vai citu apvienību vienumi netiks iekļauti.", + "message": "Tiks izgūta tikai apvienības glabātava, kas ir saistīta ar $ORGANIZATION$. Atsevišķu glabātavu vai citu apvienību vienumi netiks iekļauti.", "placeholders": { "organization": { "content": "$1", diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index 36067eec14f2..ca944a6bc2fd 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "ലോഗ് ഔട്ട് ചെയ്തിരിക്കുന്നു" }, diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index 59167b94d9d5..99c15045cc9f 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index 59167b94d9d5..99c15045cc9f 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index 13a5eaf0f44a..15d8a6aeef77 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logget av" }, diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index e0c227c35787..c67c7570a114 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index 2c6e1803227d..70a171bf756c 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Toegangsniveau" }, + "accessing": { + "message": "Toegang verkrijgen" + }, "loggedOut": { "message": "Uitgelogd" }, diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index 903acc72e107..b9231eac28a5 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index 59167b94d9d5..99c15045cc9f 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 621a81147ed2..1cd176861cf1 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Poziom dostępu" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Wylogowano" }, diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 9b6f32309d77..b7a248ff6915 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Nível de acesso" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Sessão encerrada" }, diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index 341c12de9761..30d8dad0ca6d 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Nível de acesso" }, + "accessing": { + "message": "A aceder" + }, "loggedOut": { "message": "Sessão terminada" }, diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index 4995ca9f2b03..50b7329f760a 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Deconectat" }, diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index 6e433fb2a8ea..c331541cfd9d 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Уровень доступа" }, + "accessing": { + "message": "Доступ" + }, "loggedOut": { "message": "Вы вышли из хранилища" }, diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index a1410974d793..2e9b80899c1e 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index 85a8d39fd910..73f52e776375 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Úroveň prístupu" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Odhlásený" }, diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index d9364aa29c5f..56f6a14c3947 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Odjavljen" }, diff --git a/apps/web/src/locales/sr/messages.json b/apps/web/src/locales/sr/messages.json index 0c23abb5ccdb..be4ceddfe979 100644 --- a/apps/web/src/locales/sr/messages.json +++ b/apps/web/src/locales/sr/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Ниво приступа" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Одјављено" }, diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index d3fef41494b6..12dd9c91e8c4 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Odjavljeni ste" }, diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index 64a3007043ea..16d838f3ccf0 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Åtkomstnivå" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Utloggad" }, diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index 59167b94d9d5..99c15045cc9f 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Logged out" }, diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index b7b7d7887f5c..ed1fb0504728 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Access level" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "ออกจากระบบ" }, diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index b40091486e51..4c7862da277e 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Erişim seviyesi" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Çıkış yapıldı" }, diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index 0b1843a84c3e..a937da85ee8a 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Рівень доступу" }, + "accessing": { + "message": "Доступ" + }, "loggedOut": { "message": "Ви вийшли" }, diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index 5a7c513faed6..55a34e2f2ab4 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "Cấp độ truy cập" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "Đã đăng xuất" }, diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 7babb5dadeff..652a1f3d6718 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "访问权限等级" }, + "accessing": { + "message": "访问中" + }, "loggedOut": { "message": "已注销" }, @@ -2102,7 +2105,7 @@ "message": "Bitwarden 家庭版计划。" }, "addons": { - "message": "附加项目" + "message": "插件" }, "premiumAccess": { "message": "高级会员" diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index 61cad90e3ae2..b4abbc7f3e2c 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -581,6 +581,9 @@ "accessLevel": { "message": "存取等級" }, + "accessing": { + "message": "Accessing" + }, "loggedOut": { "message": "已登出" }, diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f6ea012c3685..cd3e969e2d55 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -12,6 +12,9 @@ "@bitwarden/billing": ["../../libs/billing/src"], "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/components": ["../../libs/components/src"], + "@bitwarden/generator-components": ["../../libs/tools/generator/components/src"], + "@bitwarden/generator-core": ["../../libs/tools/generator/core/src"], + "@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"], "@bitwarden/vault-export-core": [ "../../libs/tools/export/vault-export/vault-export-core/src" ], diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index 815a8aff9e34..f22d98f081da 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -68,8 +68,7 @@ const moduleRules = [ { loader: "babel-loader", options: { - configFile: false, - plugins: ["@angular/compiler-cli/linker/babel"], + configFile: "../../babel.config.json", }, }, ], diff --git a/babel.config.json b/babel.config.json new file mode 100644 index 000000000000..4d817f0abf44 --- /dev/null +++ b/babel.config.json @@ -0,0 +1,11 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "bugfixes": true + } + ] + ], + "plugins": ["@angular/compiler-cli/linker/babel"] +} diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/approve-all.command.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/approve-all.command.ts index a3a6c4943f85..3214a0fc41e4 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/approve-all.command.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/approve-all.command.ts @@ -1,9 +1,52 @@ +import { firstValueFrom } from "rxjs"; + +import { OrganizationAuthRequestService } from "@bitwarden/bit-common/admin-console/auth-requests"; import { Response } from "@bitwarden/cli/models/response"; +import { MessageResponse } from "@bitwarden/cli/models/response/message.response"; +import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; export class ApproveAllCommand { - constructor() {} + constructor( + private organizationAuthRequestService: OrganizationAuthRequestService, + private organizationService: OrganizationService, + ) {} async run(organizationId: string): Promise { - throw new Error("Not implemented"); + if (organizationId != null) { + organizationId = organizationId.toLowerCase(); + } + + if (!Utils.isGuid(organizationId)) { + return Response.badRequest("`" + organizationId + "` is not a GUID."); + } + + const organization = await firstValueFrom(this.organizationService.get$(organizationId)); + if (!organization?.canManageUsersPassword) { + return Response.error( + "You do not have permission to approve pending device authorization requests.", + ); + } + + try { + const pendingApprovals = + await this.organizationAuthRequestService.listPendingRequests(organizationId); + if (pendingApprovals.length == 0) { + const res = new MessageResponse( + "No pending device authorization requests to approve.", + null, + ); + return Response.success(res); + } + + await this.organizationAuthRequestService.approvePendingRequests( + organizationId, + pendingApprovals, + ); + + return Response.success(); + } catch (e) { + return Response.error(e); + } } } diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/approve.command.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/approve.command.ts index b3a30165ce36..8efa172296cf 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/approve.command.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/approve.command.ts @@ -1,9 +1,54 @@ +import { firstValueFrom } from "rxjs"; + import { Response } from "@bitwarden/cli/models/response"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { OrganizationAuthRequestService } from "../../../../bit-common/src/admin-console/auth-requests"; export class ApproveCommand { - constructor() {} + constructor( + private organizationService: OrganizationService, + private organizationAuthRequestService: OrganizationAuthRequestService, + ) {} + + async run(organizationId: string, id: string): Promise { + if (organizationId != null) { + organizationId = organizationId.toLowerCase(); + } + + if (!Utils.isGuid(organizationId)) { + return Response.badRequest("`" + organizationId + "` is not a GUID."); + } + + if (id != null) { + id = id.toLowerCase(); + } + + if (!Utils.isGuid(id)) { + return Response.badRequest("`" + id + "` is not a GUID."); + } + + const organization = await firstValueFrom(this.organizationService.get$(organizationId)); + if (!organization?.canManageUsersPassword) { + return Response.error( + "You do not have permission to approve pending device authorization requests.", + ); + } + + try { + const pendingRequests = + await this.organizationAuthRequestService.listPendingRequests(organizationId); + + const request = pendingRequests.find((r) => r.id == id); + if (request == null) { + return Response.error("Invalid request id"); + } - async run(id: string): Promise { - throw new Error("Not implemented"); + await this.organizationAuthRequestService.approvePendingRequest(organizationId, request); + return Response.success(); + } catch (e) { + return Response.error(e); + } } } diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/deny-all.command.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/deny-all.command.ts index 521a7e8ded60..59cc4235ebf3 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/deny-all.command.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/deny-all.command.ts @@ -1,9 +1,49 @@ +import { firstValueFrom } from "rxjs"; + import { Response } from "@bitwarden/cli/models/response"; +import { MessageResponse } from "@bitwarden/cli/models/response/message.response"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { OrganizationAuthRequestService } from "../../../../bit-common/src/admin-console/auth-requests"; export class DenyAllCommand { - constructor() {} + constructor( + private organizationService: OrganizationService, + private organizationAuthRequestService: OrganizationAuthRequestService, + ) {} async run(organizationId: string): Promise { - throw new Error("Not implemented"); + if (organizationId != null) { + organizationId = organizationId.toLowerCase(); + } + + if (!Utils.isGuid(organizationId)) { + return Response.badRequest("`" + organizationId + "` is not a GUID."); + } + + const organization = await firstValueFrom(this.organizationService.get$(organizationId)); + if (!organization?.canManageUsersPassword) { + return Response.error( + "You do not have permission to approve pending device authorization requests.", + ); + } + + try { + const pendingRequests = + await this.organizationAuthRequestService.listPendingRequests(organizationId); + if (pendingRequests.length == 0) { + const res = new MessageResponse("No pending device authorization requests to deny.", null); + return Response.success(res); + } + + await this.organizationAuthRequestService.denyPendingRequests( + organizationId, + ...pendingRequests.map((r) => r.id), + ); + return Response.success(); + } catch (e) { + return Response.error(e); + } } } diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/deny.command.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/deny.command.ts index a366bfb05a07..a9676d3fc548 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/deny.command.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/deny.command.ts @@ -1,9 +1,46 @@ +import { firstValueFrom } from "rxjs"; + import { Response } from "@bitwarden/cli/models/response"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { OrganizationAuthRequestService } from "../../../../bit-common/src/admin-console/auth-requests"; export class DenyCommand { - constructor() {} + constructor( + private organizationService: OrganizationService, + private organizationAuthRequestService: OrganizationAuthRequestService, + ) {} + + async run(organizationId: string, id: string): Promise { + if (organizationId != null) { + organizationId = organizationId.toLowerCase(); + } + + if (!Utils.isGuid(organizationId)) { + return Response.badRequest("`" + organizationId + "` is not a GUID."); + } + + if (id != null) { + id = id.toLowerCase(); + } + + if (!Utils.isGuid(id)) { + return Response.badRequest("`" + id + "` is not a GUID."); + } + + const organization = await firstValueFrom(this.organizationService.get$(organizationId)); + if (!organization?.canManageUsersPassword) { + return Response.error( + "You do not have permission to approve pending device authorization requests.", + ); + } - async run(id: string): Promise { - throw new Error("Not implemented"); + try { + await this.organizationAuthRequestService.denyPendingRequests(organizationId, id); + return Response.success(); + } catch (e) { + return Response.error(e); + } } } diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/device-approval.program.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/device-approval.program.ts index 152dd48c7b72..0b0f3bb0f91b 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/device-approval.program.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/device-approval.program.ts @@ -3,6 +3,8 @@ import { program, Command } from "commander"; import { BaseProgram } from "@bitwarden/cli/base-program"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ServiceContainer } from "../../service-container"; + import { ApproveAllCommand } from "./approve-all.command"; import { ApproveCommand } from "./approve.command"; import { DenyAllCommand } from "./deny-all.command"; @@ -10,6 +12,10 @@ import { DenyCommand } from "./deny.command"; import { ListCommand } from "./list.command"; export class DeviceApprovalProgram extends BaseProgram { + constructor(protected serviceContainer: ServiceContainer) { + super(serviceContainer); + } + register() { program.addCommand(this.deviceApprovalCommand()); } @@ -32,7 +38,10 @@ export class DeviceApprovalProgram extends BaseProgram { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); - const cmd = new ListCommand(); + const cmd = new ListCommand( + this.serviceContainer.organizationAuthRequestService, + this.serviceContainer.organizationService, + ); const response = await cmd.run(organizationId); this.processResponse(response); }); @@ -40,27 +49,34 @@ export class DeviceApprovalProgram extends BaseProgram { private approveCommand(): Command { return new Command("approve") - .argument("") + .argument("", "The id of the organization") + .argument("", "The id of the request to approve") .description("Approve a pending request") - .action(async (id: string) => { + .action(async (organizationId: string, id: string) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); - const cmd = new ApproveCommand(); - const response = await cmd.run(id); + const cmd = new ApproveCommand( + this.serviceContainer.organizationService, + this.serviceContainer.organizationAuthRequestService, + ); + const response = await cmd.run(organizationId, id); this.processResponse(response); }); } private approveAllCommand(): Command { - return new Command("approveAll") + return new Command("approve-all") .description("Approve all pending requests for an organization") .argument("") .action(async (organizationId: string) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); - const cmd = new ApproveAllCommand(); + const cmd = new ApproveAllCommand( + this.serviceContainer.organizationAuthRequestService, + this.serviceContainer.organizationService, + ); const response = await cmd.run(organizationId); this.processResponse(response); }); @@ -68,27 +84,34 @@ export class DeviceApprovalProgram extends BaseProgram { private denyCommand(): Command { return new Command("deny") - .argument("") + .argument("", "The id of the organization") + .argument("", "The id of the request to deny") .description("Deny a pending request") - .action(async (id: string) => { + .action(async (organizationId: string, id: string) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); - const cmd = new DenyCommand(); - const response = await cmd.run(id); + const cmd = new DenyCommand( + this.serviceContainer.organizationService, + this.serviceContainer.organizationAuthRequestService, + ); + const response = await cmd.run(organizationId, id); this.processResponse(response); }); } private denyAllCommand(): Command { - return new Command("denyAll") + return new Command("deny-all") .description("Deny all pending requests for an organization") .argument("") .action(async (organizationId: string) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); - const cmd = new DenyAllCommand(); + const cmd = new DenyAllCommand( + this.serviceContainer.organizationService, + this.serviceContainer.organizationAuthRequestService, + ); const response = await cmd.run(organizationId); this.processResponse(response); }); diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/list.command.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/list.command.ts index 11fb6ec3ee2c..10da11b35cb5 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/list.command.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/list.command.ts @@ -1,9 +1,42 @@ +import { firstValueFrom } from "rxjs"; + +import { OrganizationAuthRequestService } from "@bitwarden/bit-common/admin-console/auth-requests"; import { Response } from "@bitwarden/cli/models/response"; +import { ListResponse } from "@bitwarden/cli/models/response/list.response"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { PendingAuthRequestResponse } from "./pending-auth-request.response"; export class ListCommand { - constructor() {} + constructor( + private organizationAuthRequestService: OrganizationAuthRequestService, + private organizationService: OrganizationService, + ) {} async run(organizationId: string): Promise { - throw new Error("Not implemented"); + if (organizationId != null) { + organizationId = organizationId.toLowerCase(); + } + + if (!Utils.isGuid(organizationId)) { + return Response.badRequest("`" + organizationId + "` is not a GUID."); + } + + const organization = await firstValueFrom(this.organizationService.get$(organizationId)); + if (!organization?.canManageUsersPassword) { + return Response.error( + "You do not have permission to approve pending device authorization requests.", + ); + } + + try { + const requests = + await this.organizationAuthRequestService.listPendingRequests(organizationId); + const res = new ListResponse(requests.map((r) => new PendingAuthRequestResponse(r))); + return Response.success(res); + } catch (e) { + return Response.error(e); + } } } diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/pending-auth-request.response.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/pending-auth-request.response.ts new file mode 100644 index 000000000000..991b3fb8e58d --- /dev/null +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/pending-auth-request.response.ts @@ -0,0 +1,26 @@ +import { PendingAuthRequestView } from "@bitwarden/bit-common/admin-console/auth-requests/"; +import { BaseResponse } from "@bitwarden/cli/models/response/base.response"; + +export class PendingAuthRequestResponse implements BaseResponse { + object = "auth-request"; + + id: string; + userId: string; + organizationUserId: string; + email: string; + requestDeviceIdentifier: string; + requestDeviceType: string; + requestIpAddress: string; + creationDate: Date; + + constructor(authRequest: PendingAuthRequestView) { + this.id = authRequest.id; + this.userId = authRequest.userId; + this.organizationUserId = authRequest.organizationUserId; + this.email = authRequest.email; + this.requestDeviceIdentifier = authRequest.requestDeviceIdentifier; + this.requestDeviceType = authRequest.requestDeviceType; + this.requestIpAddress = authRequest.requestIpAddress; + this.creationDate = authRequest.creationDate; + } +} diff --git a/bitwarden_license/bit-cli/src/service-container.ts b/bitwarden_license/bit-cli/src/service-container.ts index 369d54113d67..995e14531d7a 100644 --- a/bitwarden_license/bit-cli/src/service-container.ts +++ b/bitwarden_license/bit-cli/src/service-container.ts @@ -1,7 +1,24 @@ +import { + OrganizationAuthRequestService, + OrganizationAuthRequestApiService, +} from "@bitwarden/bit-common/admin-console/auth-requests"; import { ServiceContainer as OssServiceContainer } from "@bitwarden/cli/service-container"; /** * Instantiates services and makes them available for dependency injection. * Any Bitwarden-licensed services should be registered here. */ -export class ServiceContainer extends OssServiceContainer {} +export class ServiceContainer extends OssServiceContainer { + organizationAuthRequestApiService: OrganizationAuthRequestApiService; + organizationAuthRequestService: OrganizationAuthRequestService; + + constructor() { + super(); + this.organizationAuthRequestApiService = new OrganizationAuthRequestApiService(this.apiService); + this.organizationAuthRequestService = new OrganizationAuthRequestService( + this.organizationAuthRequestApiService, + this.cryptoService, + this.organizationUserService, + ); + } +} diff --git a/bitwarden_license/bit-cli/tsconfig.json b/bitwarden_license/bit-cli/tsconfig.json index 1989aa08f9b5..e8a57e5eb045 100644 --- a/bitwarden_license/bit-cli/tsconfig.json +++ b/bitwarden_license/bit-cli/tsconfig.json @@ -21,7 +21,8 @@ "@bitwarden/vault-export-core": [ "../../libs/tools/export/vault-export/vault-export-core/src" ], - "@bitwarden/node/*": ["../../libs/node/src/*"] + "@bitwarden/node/*": ["../../libs/node/src/*"], + "@bitwarden/bit-common/*": ["../../bitwarden_license/bit-common/src/*"] } }, "include": ["src", "src/**/*.spec.ts"] diff --git a/bitwarden_license/bit-common/src/admin-console/auth-requests/index.ts b/bitwarden_license/bit-common/src/admin-console/auth-requests/index.ts index d8c4bacd697b..517dc8699b55 100644 --- a/bitwarden_license/bit-common/src/admin-console/auth-requests/index.ts +++ b/bitwarden_license/bit-common/src/admin-console/auth-requests/index.ts @@ -1,2 +1,4 @@ export * from "./pending-organization-auth-request.response"; export * from "./organization-auth-request.service"; +export * from "./organization-auth-request-api.service"; +export * from "./pending-auth-request.view"; diff --git a/bitwarden_license/bit-common/tsconfig.json b/bitwarden_license/bit-common/tsconfig.json index afe66845c6a9..1c81cd9a1ee2 100644 --- a/bitwarden_license/bit-common/tsconfig.json +++ b/bitwarden_license/bit-common/tsconfig.json @@ -11,6 +11,9 @@ "@bitwarden/billing": ["../../libs/billing/src"], "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/components": ["../../libs/components/src"], + "@bitwarden/generator-components": ["../../libs/tools/generator/components/src"], + "@bitwarden/generator-core": ["../../libs/tools/generator/core/src"], + "@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"], "@bitwarden/vault-export-core": [ "../../libs/tools/export/vault-export/vault-export-core/src" ], diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts index d9d553d28332..2c6742611d7d 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.ts @@ -3,9 +3,9 @@ import { ActivatedRoute, Params, Router } from "@angular/router"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ProviderUserAcceptRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-accept.request"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.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 { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.component"; @Component({ @@ -23,11 +23,11 @@ export class AcceptProviderComponent extends BaseAcceptComponent { router: Router, i18nService: I18nService, route: ActivatedRoute, - stateService: StateService, + authService: AuthService, private apiService: ApiService, platformUtilService: PlatformUtilsService, ) { - super(router, platformUtilService, i18nService, route, stateService); + super(router, platformUtilService, i18nService, route, authService); } async authedHandler(qParams: Params) { 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 d16b0a8aa2a2..ffcfcd0ad817 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -33,6 +33,7 @@ *ngIf="canAccessBilling$ | async" > + + + + {{ "loading" | i18n }} + + + + +

    + {{ "accountCredit" | i18n }} +

    +

    {{ accountCredit | currency: "$" }}

    +

    {{ "creditAppliedDesc" | i18n }}

    + +
    + + +

    {{ "paymentMethod" | i18n }}

    +

    {{ "noPaymentMethod" | i18n }}

    + + +

    + + {{ paymentMethodDescription }} +

    +
    + +
    + + +

    {{ "taxInformation" | i18n }}

    +

    {{ "taxInformationDesc" | i18n }}

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

    {{ data.callout.body }}

    + +
    +
    +
    {{ "billingPlan" | i18n }}
    +
    {{ "providerPlan" | i18n }}
    + +
    {{ data.status.label }}
    +
    + + {{ displayedStatus }} + +
    +
    + {{ data.date.label | titlecase }} +
    +
    + {{ data.date.value | date: "mediumDate" }} +
    +
    +
    + diff --git a/bitwarden_license/bit-web/src/app/billing/providers/subscription/subscription-status.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/subscription/subscription-status.component.ts new file mode 100644 index 000000000000..fa9a892254ee --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/subscription/subscription-status.component.ts @@ -0,0 +1,188 @@ +import { DatePipe } from "@angular/common"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; + +import { ProviderSubscriptionResponse } from "@bitwarden/common/billing/models/response/provider-subscription-response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +type ComponentData = { + status?: { + label: string; + value: string; + }; + date?: { + label: string; + value: string; + }; + callout?: { + severity: "danger" | "warning"; + header: string; + body: string; + showReinstatementButton: boolean; + }; +}; + +@Component({ + selector: "app-subscription-status", + templateUrl: "subscription-status.component.html", +}) +export class SubscriptionStatusComponent { + @Input({ required: true }) providerSubscriptionResponse: ProviderSubscriptionResponse; + @Output() reinstatementRequested = new EventEmitter(); + + constructor( + private datePipe: DatePipe, + private i18nService: I18nService, + ) {} + + get displayedStatus(): string { + return this.data.status.value; + } + + get planName() { + return this.providerSubscriptionResponse.plans[0]; + } + + get status(): string { + return this.subscription.status; + } + + get isExpired() { + return this.subscription.status !== "active"; + } + + get subscription() { + return this.providerSubscriptionResponse; + } + + get data(): ComponentData { + const defaultStatusLabel = this.i18nService.t("status"); + + const nextChargeDateLabel = this.i18nService.t("nextCharge"); + const subscriptionExpiredDateLabel = this.i18nService.t("subscriptionExpired"); + const cancellationDateLabel = this.i18nService.t("cancellationDate"); + + switch (this.status) { + case "free": { + return {}; + } + case "trialing": { + return { + status: { + label: defaultStatusLabel, + value: this.i18nService.t("trial"), + }, + date: { + label: nextChargeDateLabel, + value: this.subscription.currentPeriodEndDate.toDateString(), + }, + }; + } + case "active": { + return { + status: { + label: defaultStatusLabel, + value: this.i18nService.t("active"), + }, + date: { + label: nextChargeDateLabel, + value: this.subscription.currentPeriodEndDate.toDateString(), + }, + }; + } + case "past_due": { + const pastDueText = this.i18nService.t("pastDue"); + const suspensionDate = this.datePipe.transform( + this.subscription.suspensionDate, + "mediumDate", + ); + const calloutBody = + this.subscription.collectionMethod === "charge_automatically" + ? this.i18nService.t( + "pastDueWarningForChargeAutomatically", + this.subscription.gracePeriod, + suspensionDate, + ) + : this.i18nService.t( + "pastDueWarningForSendInvoice", + this.subscription.gracePeriod, + suspensionDate, + ); + return { + status: { + label: defaultStatusLabel, + value: pastDueText, + }, + date: { + label: subscriptionExpiredDateLabel, + value: this.subscription.unpaidPeriodEndDate, + }, + callout: { + severity: "warning", + header: pastDueText, + body: calloutBody, + showReinstatementButton: false, + }, + }; + } + case "unpaid": { + return { + status: { + label: defaultStatusLabel, + value: this.i18nService.t("unpaid"), + }, + date: { + label: subscriptionExpiredDateLabel, + value: this.subscription.currentPeriodEndDate.toDateString(), + }, + callout: { + severity: "danger", + header: this.i18nService.t("unpaidInvoice"), + body: this.i18nService.t("toReactivateYourSubscription"), + showReinstatementButton: false, + }, + }; + } + case "pending_cancellation": { + const pendingCancellationText = this.i18nService.t("pendingCancellation"); + return { + status: { + label: defaultStatusLabel, + value: pendingCancellationText, + }, + date: { + label: cancellationDateLabel, + value: this.subscription.currentPeriodEndDate.toDateString(), + }, + callout: { + severity: "warning", + header: pendingCancellationText, + body: this.i18nService.t("subscriptionPendingCanceled"), + showReinstatementButton: true, + }, + }; + } + case "incomplete_expired": + case "canceled": { + const canceledText = this.i18nService.t("canceled"); + return { + status: { + label: defaultStatusLabel, + value: canceledText, + }, + date: { + label: cancellationDateLabel, + value: this.subscription.currentPeriodEndDate.toDateString(), + }, + callout: { + severity: "danger", + header: canceledText, + body: this.i18nService.t("subscriptionCanceled"), + showReinstatementButton: false, + }, + }; + } + } + } + + requestReinstatement = () => this.reinstatementRequested.emit(); +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts index 95c176425382..56c02e1ed436 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -17,6 +17,7 @@ import { import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { DialogService } from "@bitwarden/components"; @@ -94,6 +95,7 @@ export class OverviewComponent implements OnInit, OnDestroy { private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private smOnboardingTasksService: SMOnboardingTasksService, + private logService: LogService, ) {} ngOnInit() { @@ -297,12 +299,13 @@ export class OverviewComponent implements OnInit, OnDestroy { SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService); } - copySecretValue(id: string) { - SecretsListComponent.copySecretValue( + async copySecretValue(id: string) { + await SecretsListComponent.copySecretValue( id, this.platformUtilsService, this.i18nService, this.secretService, + this.logService, ); } @@ -310,11 +313,9 @@ export class OverviewComponent implements OnInit, OnDestroy { SecretsListComponent.copySecretUuid(id, this.platformUtilsService, this.i18nService); } - protected hideOnboarding() { + protected async hideOnboarding() { this.showOnboarding = false; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.saveCompletedTasks(this.organizationId, { + await this.saveCompletedTasks(this.organizationId, { importSecrets: true, createSecret: true, createProject: true, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts index 0b65bd0a26bf..d30d5f664e28 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts @@ -82,9 +82,7 @@ export class ProjectDialogComponent implements OnInit { const projectView = this.getProjectView(); if (this.data.operation === OperationType.Add) { const newProject = await this.createProject(projectView); - // 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(["sm", this.data.organizationId, "projects", newProject.id]); + await this.router.navigate(["sm", this.data.organizationId, "projects", newProject.id]); } else { projectView.id = this.data.projectId; await this.updateProject(projectView); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/guards/project-access.guard.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/guards/project-access.guard.spec.ts new file mode 100644 index 000000000000..84bc1483fd1e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/guards/project-access.guard.spec.ts @@ -0,0 +1,120 @@ +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { MockProxy, mock } from "jest-mock-extended"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { RouterService } from "../../../../../../../apps/web/src/app/core/router.service"; +import { ProjectView } from "../../models/view/project.view"; +import { ProjectService } from "../project.service"; + +import { projectAccessGuard } from "./project-access.guard"; + +@Component({ + template: "", +}) +export class GuardedRouteTestComponent {} + +@Component({ + template: "", +}) +export class RedirectTestComponent {} + +describe("Project Redirect Guard", () => { + let organizationService: MockProxy; + let routerService: MockProxy; + let projectServiceMock: MockProxy; + let i18nServiceMock: MockProxy; + let platformUtilsService: MockProxy; + let router: Router; + + const smOrg1 = { id: "123", canAccessSecretsManager: true } as Organization; + const projectView = { + id: "123", + organizationId: "123", + name: "project-name", + creationDate: Date.now.toString(), + revisionDate: Date.now.toString(), + read: true, + write: true, + } as ProjectView; + + beforeEach(async () => { + organizationService = mock(); + routerService = mock(); + projectServiceMock = mock(); + i18nServiceMock = mock(); + platformUtilsService = mock(); + + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([ + { + path: "sm/:organizationId/projects/:projectId", + component: GuardedRouteTestComponent, + canActivate: [projectAccessGuard], + }, + { + path: "sm", + component: RedirectTestComponent, + }, + { + path: "sm/:organizationId/projects", + component: RedirectTestComponent, + }, + ]), + ], + providers: [ + { provide: OrganizationService, useValue: organizationService }, + { provide: RouterService, useValue: routerService }, + { provide: ProjectService, useValue: projectServiceMock }, + { provide: I18nService, useValue: i18nServiceMock }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + ], + }); + + router = TestBed.inject(Router); + }); + + it("redirects to sm/{orgId}/projects/{projectId} if project exists", async () => { + // Arrange + organizationService.getAll.mockResolvedValue([smOrg1]); + projectServiceMock.getByProjectId.mockReturnValue(Promise.resolve(projectView)); + + // Act + await router.navigateByUrl("sm/123/projects/123"); + + // Assert + expect(router.url).toBe("/sm/123/projects/123"); + }); + + it("redirects to sm/projects if project does not exist", async () => { + // Arrange + organizationService.getAll.mockResolvedValue([smOrg1]); + + // Act + await router.navigateByUrl("sm/123/projects/124"); + + // Assert + expect(router.url).toBe("/sm/123/projects"); + }); + + it("redirects to sm/123/projects if exception occurs while looking for Project", async () => { + // Arrange + jest.spyOn(projectServiceMock, "getByProjectId").mockImplementation(() => { + throw new Error("Test error"); + }); + jest.spyOn(i18nServiceMock, "t").mockReturnValue("Project not found"); + + // Act + await router.navigateByUrl("sm/123/projects/123"); + // Assert + expect(platformUtilsService.showToast).toHaveBeenCalledWith("error", null, "Project not found"); + expect(router.url).toBe("/sm/123/projects"); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/guards/project-access.guard.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/guards/project-access.guard.ts new file mode 100644 index 000000000000..6c08fcc3aa7d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/guards/project-access.guard.ts @@ -0,0 +1,31 @@ +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn, createUrlTreeFromSnapshot } from "@angular/router"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { ProjectService } from "../project.service"; + +/** + * Redirects to projects list if the user doesn't have access to project. + */ +export const projectAccessGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => { + const projectService = inject(ProjectService); + const platformUtilsService = inject(PlatformUtilsService); + const i18nService = inject(I18nService); + + try { + const project = await projectService.getByProjectId(route.params.projectId); + if (project) { + return true; + } + } catch { + platformUtilsService.showToast( + "error", + null, + i18nService.t("notFound", i18nService.t("project")), + ); + return createUrlTreeFromSnapshot(route, ["/sm", route.params.organizationId, "projects"]); + } + return createUrlTreeFromSnapshot(route, ["/sm", route.params.organizationId, "projects"]); +}; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts index 835d3825a05a..c49008c580b9 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from "@angular/router"; import { combineLatest, Subject, switchMap, takeUntil, catchError } from "rxjs"; 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 { DialogService } from "@bitwarden/components"; @@ -38,8 +39,9 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy { }), ), catchError(async () => { + this.logService.info("Error fetching project people access policies."); await this.router.navigate(["/sm", this.organizationId, "projects"]); - return []; + return undefined; }), ); @@ -70,6 +72,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy { private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private accessPolicySelectorService: AccessPolicySelectorService, + private logService: LogService, ) {} ngOnInit(): void { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts index 07d50b28ee18..21d6e576a014 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts @@ -4,6 +4,7 @@ import { combineLatest, combineLatestWith, filter, Observable, startWith, switch import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { DialogService } from "@bitwarden/components"; @@ -42,6 +43,7 @@ export class ProjectSecretsComponent { private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private organizationService: OrganizationService, + private logService: LogService, ) {} ngOnInit() { @@ -109,12 +111,13 @@ export class ProjectSecretsComponent { SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService); } - copySecretValue(id: string) { - SecretsListComponent.copySecretValue( + async copySecretValue(id: string) { + await SecretsListComponent.copySecretValue( id, this.platformUtilsService, this.i18nService, this.secretService, + this.logService, ); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts index 742c2bea1d8f..07ca32600a91 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts @@ -1,9 +1,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { - catchError, combineLatest, - EMPTY, filter, Observable, startWith, @@ -58,18 +56,6 @@ export class ProjectComponent implements OnInit, OnDestroy { this.project$ = combineLatest([this.route.params, currentProjectEdited]).pipe( switchMap(([params, _]) => this.projectService.getByProjectId(params.projectId)), - catchError(() => { - // 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(["/sm", this.organizationId, "projects"]).then(() => { - this.platformUtilsService.showToast( - "error", - null, - this.i18nService.t("notFound", this.i18nService.t("project")), - ); - }); - return EMPTY; - }), ); const projectId$ = this.route.params.pipe(map((p) => p.projectId)); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-routing.module.ts index 6078520989ab..231486703c9f 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-routing.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { projectAccessGuard } from "./guards/project-access.guard"; import { ProjectPeopleComponent } from "./project/project-people.component"; import { ProjectSecretsComponent } from "./project/project-secrets.component"; import { ProjectServiceAccountsComponent } from "./project/project-service-accounts.component"; @@ -15,6 +16,7 @@ const routes: Routes = [ { path: ":projectId", component: ProjectComponent, + canActivate: [projectAccessGuard], children: [ { path: "", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts index b1bd91a04fbe..0287cdd42515 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts @@ -199,7 +199,7 @@ export class SecretDialogComponent implements OnInit { return await this.projectService.create(this.data.organizationId, projectView); } - protected openDeleteSecretDialog() { + protected async openDeleteSecretDialog() { const secretListView: SecretListView[] = this.getSecretListView(); const dialogRef = this.dialogService.open( @@ -212,9 +212,7 @@ export class SecretDialogComponent implements OnInit { ); // If the secret is deleted, chain close this dialog after the delete dialog - // 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 - lastValueFrom(dialogRef.closed).then( + await lastValueFrom(dialogRef.closed).then( (closeData) => closeData !== undefined && this.dialogRef.close(), ); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts index a7413c9b59fd..2717f96a6867 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts @@ -4,6 +4,7 @@ import { combineLatestWith, Observable, startWith, switchMap } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; @@ -39,6 +40,7 @@ export class SecretsComponent implements OnInit { private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private organizationService: OrganizationService, + private logService: LogService, ) {} ngOnInit() { @@ -97,12 +99,13 @@ export class SecretsComponent implements OnInit { SecretsListComponent.copySecretName(name, this.platformUtilsService, this.i18nService); } - copySecretValue(id: string) { - SecretsListComponent.copySecretValue( + async copySecretValue(id: string) { + await SecretsListComponent.copySecretValue( id, this.platformUtilsService, this.i18nService, this.secretService, + this.logService, ); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts index 105ca59e57f2..de753d88138b 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts @@ -47,9 +47,7 @@ export class ServiceAccountDialogComponent { async ngOnInit() { if (this.data.operation == OperationType.Edit) { - // 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.loadData(); + await this.loadData(); } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts new file mode 100644 index 000000000000..956935ac6ac2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts @@ -0,0 +1,122 @@ +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { MockProxy, mock } from "jest-mock-extended"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { RouterService } from "../../../../../../../../clients/apps/web/src/app/core/router.service"; +import { ServiceAccountView } from "../../models/view/service-account.view"; +import { ServiceAccountService } from "../service-account.service"; + +import { serviceAccountAccessGuard } from "./service-account-access.guard"; + +@Component({ + template: "", +}) +export class GuardedRouteTestComponent {} + +@Component({ + template: "", +}) +export class RedirectTestComponent {} + +describe("Service account Redirect Guard", () => { + let organizationService: MockProxy; + let routerService: MockProxy; + let serviceAccountServiceMock: MockProxy; + let i18nServiceMock: MockProxy; + let platformUtilsService: MockProxy; + let router: Router; + + const smOrg1 = { id: "123", canAccessSecretsManager: true } as Organization; + const serviceAccountView = { + id: "123", + organizationId: "123", + name: "service-account-name", + } as ServiceAccountView; + + beforeEach(async () => { + organizationService = mock(); + routerService = mock(); + serviceAccountServiceMock = mock(); + i18nServiceMock = mock(); + platformUtilsService = mock(); + + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([ + { + path: "sm/:organizationId/machine-accounts/:serviceAccountId", + component: GuardedRouteTestComponent, + canActivate: [serviceAccountAccessGuard], + }, + { + path: "sm", + component: RedirectTestComponent, + }, + { + path: "sm/:organizationId/machine-accounts", + component: RedirectTestComponent, + }, + ]), + ], + providers: [ + { provide: OrganizationService, useValue: organizationService }, + { provide: RouterService, useValue: routerService }, + { provide: ServiceAccountService, useValue: serviceAccountServiceMock }, + { provide: I18nService, useValue: i18nServiceMock }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + ], + }); + + router = TestBed.inject(Router); + }); + + it("redirects to sm/{orgId}/machine-accounts/{serviceAccountId} if machine account exists", async () => { + // Arrange + organizationService.getAll.mockResolvedValue([smOrg1]); + serviceAccountServiceMock.getByServiceAccountId.mockReturnValue( + Promise.resolve(serviceAccountView), + ); + + // Act + await router.navigateByUrl("sm/123/machine-accounts/123"); + + // Assert + expect(router.url).toBe("/sm/123/machine-accounts/123"); + }); + + it("redirects to sm/machine-accounts if machine account does not exist", async () => { + // Arrange + organizationService.getAll.mockResolvedValue([smOrg1]); + + // Act + await router.navigateByUrl("sm/123/machine-accounts/124"); + + // Assert + expect(router.url).toBe("/sm/123/machine-accounts"); + }); + + it("redirects to sm/123/machine-accounts if exception occurs while looking for service account", async () => { + // Arrange + jest.spyOn(serviceAccountServiceMock, "getByServiceAccountId").mockImplementation(() => { + throw new Error("Test error"); + }); + jest.spyOn(i18nServiceMock, "t").mockReturnValue("Service account not found"); + + // Act + await router.navigateByUrl("sm/123/machine-accounts/123"); + // Assert + expect(platformUtilsService.showToast).toHaveBeenCalledWith( + "error", + null, + "Service account not found", + ); + expect(router.url).toBe("/sm/123/machine-accounts"); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.ts index c474ec44d553..b72fc5a1fe2a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.ts @@ -1,6 +1,9 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, CanActivateFn, createUrlTreeFromSnapshot } from "@angular/router"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + import { ServiceAccountService } from "../service-account.service"; /** @@ -8,6 +11,8 @@ import { ServiceAccountService } from "../service-account.service"; */ export const serviceAccountAccessGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => { const serviceAccountService = inject(ServiceAccountService); + const platformUtilsService = inject(PlatformUtilsService); + const i18nService = inject(I18nService); try { const serviceAccount = await serviceAccountService.getByServiceAccountId( @@ -18,6 +23,12 @@ export const serviceAccountAccessGuard: CanActivateFn = async (route: ActivatedR return true; } } catch { + platformUtilsService.showToast( + "error", + null, + i18nService.t("notFound", i18nService.t("machineAccount")), + ); + return createUrlTreeFromSnapshot(route, [ "/sm", route.params.organizationId, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts index bb687c51c626..51b663acce6e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts @@ -1,15 +1,6 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { - EMPTY, - Subject, - catchError, - combineLatest, - filter, - startWith, - switchMap, - takeUntil, -} from "rxjs"; +import { Subject, combineLatest, filter, startWith, switchMap, takeUntil } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -42,18 +33,6 @@ export class ServiceAccountComponent implements OnInit, OnDestroy { params.organizationId, ), ), - catchError(() => { - // 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(["/sm", this.organizationId, "machine-accounts"]).then(() => { - this.platformUtilsService.showToast( - "error", - null, - this.i18nService.t("notFound", this.i18nService.t("machineAccount")), - ); - }); - return EMPTY; - }), ); constructor( diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html index e926ba6a13d9..454b497fcdb9 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html @@ -78,9 +78,11 @@ -
    - {{ emptyMessage }} -
    + + + {{ emptyMessage }} + +
    diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html index f8d5d1081e07..4b629ca4885b 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.html @@ -93,8 +93,9 @@ variant="secondary" class="tw-ml-1" [title]="project.name" + maxWidthClass="tw-max-w-60" > - {{ project.name | ellipsis: 32 }} + {{ project.name }} secret.value); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - SecretsListComponent.copyToClipboardAsync(value, platformUtilsService).then(() => { + try { + const value = await secretService.getBySecretId(id).then((secret) => secret.value); + platformUtilsService.copyToClipboard(value); platformUtilsService.showToast( "success", null, i18nService.t("valueCopied", i18nService.t("value")), ); - }); + } catch { + logService.info("Error fetching secret value."); + } } static copySecretUuid( diff --git a/bitwarden_license/bit-web/tsconfig.json b/bitwarden_license/bit-web/tsconfig.json index 05517df57c83..26f8e655390f 100644 --- a/bitwarden_license/bit-web/tsconfig.json +++ b/bitwarden_license/bit-web/tsconfig.json @@ -12,6 +12,9 @@ "@bitwarden/billing": ["../../libs/billing/src"], "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/components": ["../../libs/components/src"], + "@bitwarden/generator-components": ["../../libs/tools/generator/components/src"], + "@bitwarden/generator-core": ["../../libs/tools/generator/core/src"], + "@bitwarden/generator-extensions": ["../../libs/tools/generator/extensions/src"], "@bitwarden/vault-export-core": [ "../../libs/tools/export/vault-export/vault-export-core/src" ], diff --git a/jest.config.js b/jest.config.js index e2c50553d8e8..f4e97262a39f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -31,6 +31,7 @@ module.exports = { "/libs/common/jest.config.js", "/libs/components/jest.config.js", "/libs/tools/export/vault-export/vault-export-core/jest.config.js", + "/libs/tools/generator/core/jest.config.js", "/libs/importer/jest.config.js", "/libs/platform/jest.config.js", "/libs/node/jest.config.js", 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 401abab3b193..f898fa4ee8d5 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 @@ -246,7 +246,10 @@ export class LoginViaAuthRequestComponent const deviceIdentifier = await this.appIdService.getAppId(); const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey); - const accessCode = await this.passwordGenerationService.generatePassword({ length: 25 }); + const accessCode = await this.passwordGenerationService.generatePassword({ + type: "password", + length: 25, + }); this.fingerprintPhrase = ( await this.cryptoService.getFingerprint(this.email, this.authRequestKeyPair.publicKey) diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index bcdf747406ec..cfef72c435cc 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -155,7 +155,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, if (this.handleCaptchaRequired(response)) { return; - } else if (this.handleMigrateEncryptionKey(response)) { + } else if (await this.handleMigrateEncryptionKey(response)) { return; } else if (response.requiresTwoFactor) { if (this.onSuccessfulLoginTwoFactorNavigate != null) { @@ -218,9 +218,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, } 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"]); + await this.router.navigate(["/login-with-device"]); } async launchSsoBrowser(clientId: string, ssoRedirectUri: string) { @@ -310,7 +308,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, // Legacy accounts used the master key to encrypt data. Migration is required // but only performed on web - protected handleMigrateEncryptionKey(result: AuthResult): boolean { + protected async handleMigrateEncryptionKey(result: AuthResult): Promise { if (!result.requiresEncryptionKeyMigration) { return false; } diff --git a/libs/angular/src/auth/components/register.component.ts b/libs/angular/src/auth/components/register.component.ts index 2ba766929026..e3197355dc3c 100644 --- a/libs/angular/src/auth/components/register.component.ts +++ b/libs/angular/src/auth/components/register.component.ts @@ -78,6 +78,10 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn protected captchaBypassToken: string = null; + // allows for extending classes to modify the register request before sending + // currently used by web to add organization invitation details + protected modifyRegisterRequest: (request: RegisterRequest) => Promise; + constructor( protected formValidationErrorService: FormValidationErrorsService, protected formBuilder: UntypedFormBuilder, @@ -290,10 +294,8 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn kdfConfig.iterations, ); request.keys = new KeysRequest(keys[0], keys[1].encryptedString); - const orgInvite = await this.stateService.getOrganizationInvitation(); - if (orgInvite != null && orgInvite.token != null && orgInvite.organizationUserId != null) { - request.token = orgInvite.token; - request.organizationUserId = orgInvite.organizationUserId; + if (this.modifyRegisterRequest) { + await this.modifyRegisterRequest(request); } return request; } diff --git a/libs/angular/src/auth/components/update-password.component.ts b/libs/angular/src/auth/components/update-password.component.ts index 3b709b3e7f7d..62e0359038c4 100644 --- a/libs/angular/src/auth/components/update-password.component.ts +++ b/libs/angular/src/auth/components/update-password.component.ts @@ -72,10 +72,7 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent { } async cancel() { - await this.stateService.setOrganizationInvitation(null); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/vault"]); + await this.router.navigate(["/vault"]); } async setupSubmitActions(): Promise { diff --git a/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.html b/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.html new file mode 100644 index 000000000000..c9c0c296ada8 --- /dev/null +++ b/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.html @@ -0,0 +1,55 @@ +
    + + +

    {{ "creditDelayed" | i18n }}

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

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

    +
    + + {{ "amountX" | i18n: "1" }} + + $0. + + + {{ "amountX" | i18n: "2" }} + + $0. + + +
    +
    diff --git a/libs/angular/src/billing/components/verify-bank-account/verify-bank-account.component.ts b/libs/angular/src/billing/components/verify-bank-account/verify-bank-account.component.ts new file mode 100644 index 000000000000..c8abb65d8195 --- /dev/null +++ b/libs/angular/src/billing/components/verify-bank-account/verify-bank-account.component.ts @@ -0,0 +1,33 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { FormBuilder, FormControl, Validators } from "@angular/forms"; + +@Component({ + selector: "app-verify-bank-account", + templateUrl: "./verify-bank-account.component.html", +}) +export class VerifyBankAccountComponent { + @Input() onSubmit?: (amount1: number, amount2: number) => Promise; + @Output() verificationSubmitted = new EventEmitter(); + + protected formGroup = this.formBuilder.group({ + amount1: new FormControl(null, [ + Validators.required, + Validators.min(0), + Validators.max(99), + ]), + amount2: new FormControl(null, [ + Validators.required, + Validators.min(0), + Validators.max(99), + ]), + }); + + constructor(private formBuilder: FormBuilder) {} + + submit = async () => { + if (this.onSubmit) { + await this.onSubmit(this.formGroup.value.amount1, this.formGroup.value.amount2); + } + this.verificationSubmitted.emit(); + }; +} diff --git a/libs/angular/src/billing/images/cards.png b/libs/angular/src/billing/images/cards.png new file mode 100644 index 000000000000..bd43abe54c56 Binary files /dev/null and b/libs/angular/src/billing/images/cards.png differ diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index 5f1bf796aa95..ccb7446d863a 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -2,7 +2,24 @@ import { CommonModule, DatePipe } from "@angular/common"; import { NgModule } from "@angular/core"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; -import { AutofocusDirective, ToastModule } from "@bitwarden/components"; +import { + AddAccountCreditDialogComponent, + ManageTaxInformationComponent, + SelectPaymentMethodComponent, + VerifyBankAccountComponent, +} from "@bitwarden/angular/billing/components"; +import { + AsyncActionsModule, + AutofocusDirective, + ButtonModule, + CheckboxModule, + DialogModule, + FormFieldModule, + RadioButtonModule, + SelectModule, + ToastModule, + TypographyModule, +} from "@bitwarden/components"; import { CalloutComponent } from "./components/callout.component"; import { A11yInvalidDirective } from "./directives/a11y-invalid.directive"; @@ -41,6 +58,14 @@ import { IconComponent } from "./vault/components/icon.component"; CommonModule, FormsModule, ReactiveFormsModule, + AsyncActionsModule, + RadioButtonModule, + FormFieldModule, + SelectModule, + ButtonModule, + CheckboxModule, + DialogModule, + TypographyModule, ], declarations: [ A11yInvalidDirective, @@ -70,6 +95,10 @@ import { IconComponent } from "./vault/components/icon.component"; UserTypePipe, IfFeatureDirective, FingerprintPipe, + AddAccountCreditDialogComponent, + ManageTaxInformationComponent, + SelectPaymentMethodComponent, + VerifyBankAccountComponent, ], exports: [ A11yInvalidDirective, @@ -100,6 +129,10 @@ import { IconComponent } from "./vault/components/icon.component"; UserTypePipe, IfFeatureDirective, FingerprintPipe, + AddAccountCreditDialogComponent, + ManageTaxInformationComponent, + SelectPaymentMethodComponent, + VerifyBankAccountComponent, ], providers: [ CreditCardNumberPipe, diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 17a98498d689..40405b062c61 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -1,6 +1,7 @@ import { InjectionToken } from "@angular/core"; import { Observable, Subject } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; import { ClientType } from "@bitwarden/common/enums"; import { AbstractStorageService, @@ -36,7 +37,7 @@ export const MEMORY_STORAGE = new SafeInjectionToken("ME export const SECURE_STORAGE = new SafeInjectionToken("SECURE_STORAGE"); export const STATE_FACTORY = new SafeInjectionToken("STATE_FACTORY"); export const LOGOUT_CALLBACK = new SafeInjectionToken< - (expired: boolean, userId?: string) => Promise + (logoutReason: LogoutReason, userId?: string) => Promise >("LOGOUT_CALLBACK"); export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promise>( "LOCKED_CALLBACK", @@ -53,3 +54,7 @@ export const INTRAPROCESS_MESSAGING_SUBJECT = new SafeInjectionToken< Subject>> >("INTRAPROCESS_MESSAGING_SUBJECT"); export const CLIENT_TYPE = new SafeInjectionToken("CLIENT_TYPE"); + +export const REFRESH_ACCESS_TOKEN_ERROR_CALLBACK = new SafeInjectionToken<() => void>( + "REFRESH_ACCESS_TOKEN_ERROR_CALLBACK", +); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 60f83934af7b..8c676bdb9d9e 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -13,6 +13,7 @@ import { InternalUserDecryptionOptionsServiceAbstraction, UserDecryptionOptionsService, UserDecryptionOptionsServiceAbstraction, + LogoutReason, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -109,14 +110,20 @@ import { DomainSettingsService, DefaultDomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { + BillingApiServiceAbstraction, + BraintreeServiceAbstraction, + OrganizationBillingServiceAbstraction, + PaymentMethodWarningsServiceAbstraction, + StripeServiceAbstraction, +} from "@bitwarden/common/billing/abstractions"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; -import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-billing.service"; -import { PaymentMethodWarningsServiceAbstraction } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { PaymentMethodWarningsService } from "@bitwarden/common/billing/services/payment-method-warnings.service"; +import { BraintreeService } from "@bitwarden/common/billing/services/payment-processors/braintree.service"; +import { StripeService } from "@bitwarden/common/billing/services/payment-processors/stripe.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; @@ -180,6 +187,9 @@ import { DefaultStateProvider } from "@bitwarden/common/platform/state/implement import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service"; import { StateEventRunnerService } from "@bitwarden/common/platform/state/state-event-runner.service"; /* eslint-enable import/no-restricted-paths */ +import { SyncService } from "@bitwarden/common/platform/sync"; +// eslint-disable-next-line no-restricted-imports -- Needed for DI +import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal"; import { DefaultThemeStateService, ThemeStateService, @@ -219,8 +229,6 @@ import { FolderService as FolderServiceAbstraction, InternalFolderService, } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { SyncNotifierService as SyncNotifierServiceAbstraction } from "@bitwarden/common/vault/abstractions/sync/sync-notifier.service.abstraction"; -import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; @@ -228,10 +236,9 @@ import { CollectionService } from "@bitwarden/common/vault/services/collection.s import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; -import { SyncNotifierService } from "@bitwarden/common/vault/services/sync/sync-notifier.service"; -import { SyncService } from "@bitwarden/common/vault/services/sync/sync.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service"; +import { ToastService } from "@bitwarden/components"; import { ImportApiService, ImportApiServiceAbstraction, @@ -275,6 +282,7 @@ import { DEFAULT_VAULT_TIMEOUT, INTRAPROCESS_MESSAGING_SUBJECT, CLIENT_TYPE, + REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, } from "./injection-tokens"; import { ModalService } from "./modal.service"; @@ -316,8 +324,12 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: LOGOUT_CALLBACK, useFactory: - (messagingService: MessagingServiceAbstraction) => (expired: boolean, userId?: string) => - Promise.resolve(messagingService.send("logout", { expired: expired, userId: userId })), + (messagingService: MessagingServiceAbstraction) => + async (logoutReason: LogoutReason, userId?: string) => { + return Promise.resolve( + messagingService.send("logout", { logoutReason: logoutReason, userId: userId }), + ); + }, deps: [MessagingServiceAbstraction], }), safeProvider({ @@ -526,6 +538,7 @@ const safeProviders: SafeProvider[] = [ KeyGenerationServiceAbstraction, EncryptService, LogService, + LOGOUT_CALLBACK, ], }), safeProvider({ @@ -579,6 +592,17 @@ const safeProviders: SafeProvider[] = [ StateProvider, ], }), + safeProvider({ + provide: REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, + useFactory: (toastService: ToastService, i18nService: I18nServiceAbstraction) => () => { + toastService.showToast({ + variant: "error", + title: i18nService.t("errorRefreshingAccessToken"), + message: i18nService.t("errorRefreshingAccessTokenDesc"), + }); + }, + deps: [ToastService, I18nServiceAbstraction], + }), safeProvider({ provide: ApiServiceAbstraction, useClass: ApiService, @@ -587,8 +611,10 @@ const safeProviders: SafeProvider[] = [ PlatformUtilsServiceAbstraction, EnvironmentService, AppIdServiceAbstraction, - VaultTimeoutSettingsServiceAbstraction, + REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, + LogService, LOGOUT_CALLBACK, + VaultTimeoutSettingsServiceAbstraction, ], }), safeProvider({ @@ -617,8 +643,8 @@ const safeProviders: SafeProvider[] = [ deps: [ApiServiceAbstraction, FileUploadServiceAbstraction, InternalSendService], }), safeProvider({ - provide: SyncServiceAbstraction, - useClass: SyncService, + provide: SyncService, + useClass: DefaultSyncService, deps: [ InternalMasterPasswordServiceAbstraction, AccountServiceAbstraction, @@ -769,7 +795,7 @@ const safeProviders: SafeProvider[] = [ useClass: devFlagEnabled("noopNotifications") ? NoopNotificationsService : NotificationsService, deps: [ LogService, - SyncServiceAbstraction, + SyncService, AppIdServiceAbstraction, ApiServiceAbstraction, EnvironmentService, @@ -915,12 +941,7 @@ const safeProviders: SafeProvider[] = [ // it depends on SyncService so that new data can be retrieved through the sync // rather than updating the OrganizationService directly. Instead OrganizationService // subscribes to sync notifications and will update itself based on that. - deps: [ApiServiceAbstraction, SyncServiceAbstraction], - }), - safeProvider({ - provide: SyncNotifierServiceAbstraction, - useClass: SyncNotifierService, - deps: [], + deps: [ApiServiceAbstraction, SyncService], }), safeProvider({ provide: DefaultConfigService, @@ -1095,7 +1116,7 @@ const safeProviders: SafeProvider[] = [ EncryptService, I18nServiceAbstraction, OrganizationApiServiceAbstraction, - SyncServiceAbstraction, + SyncService, ], }), safeProvider({ @@ -1190,6 +1211,16 @@ const safeProviders: SafeProvider[] = [ useClass: KdfConfigService, deps: [StateProvider], }), + safeProvider({ + provide: BraintreeServiceAbstraction, + useClass: BraintreeService, + deps: [LogService], + }), + safeProvider({ + provide: StripeServiceAbstraction, + useClass: StripeService, + deps: [LogService], + }), ]; function encryptServiceFactory( diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html index 4b593d336e14..b6eeb70d5d5a 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.html +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html @@ -13,9 +13,11 @@

    {{ subtitle }}

    -
    +
    diff --git a/libs/auth/src/angular/anon-layout/anon-layout.stories.ts b/libs/auth/src/angular/anon-layout/anon-layout.stories.ts index 82ca846afbfc..c9054fb5e636 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.stories.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout.stories.ts @@ -119,6 +119,24 @@ export const WithLongContent: Story = { }), }; +export const WithThinPrimaryContent: Story = { + render: (args) => ({ + props: args, + template: + // Projected content (the
    's) and styling is just a sample and can be replaced with any content/styling. + ` + +
    Lorem ipsum
    + +
    +
    Secondary Projected Content (optional)
    + +
    +
    + `, + }), +}; + export const WithIcon: Story = { render: (args) => ({ props: args, diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html index b4dad835eec5..9785bf05ab58 100644 --- a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html @@ -8,6 +8,7 @@ [label]="regionConfig.domain" > diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts index f01873dd3e20..fe41f0a3ac74 100644 --- a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts @@ -1,17 +1,26 @@ import { CommonModule } from "@angular/common"; import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; -import { EMPTY, Subject, from, map, of, switchMap, takeUntil, tap } from "rxjs"; +import { Subject, from, map, of, pairwise, startWith, switchMap, takeUntil, tap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ClientType } from "@bitwarden/common/enums"; import { Environment, EnvironmentService, Region, RegionConfig, } from "@bitwarden/common/platform/abstractions/environment.service"; -import { FormFieldModule, SelectModule } from "@bitwarden/components"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService, FormFieldModule, SelectModule, ToastService } from "@bitwarden/components"; +import { RegistrationSelfHostedEnvConfigDialogComponent } from "./registration-self-hosted-env-config-dialog.component"; + +/** + * Component for selecting the environment to register with in the email verification registration flow. + * Outputs the selected region to the parent component so it can respond as necessary. + */ @Component({ standalone: true, selector: "auth-registration-env-selector", @@ -19,7 +28,7 @@ import { FormFieldModule, SelectModule } from "@bitwarden/components"; imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule], }) export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy { - @Output() onOpenSelfHostedSettings = new EventEmitter(); + @Output() selectedRegionChange = new EventEmitter(); ServerEnvironmentType = Region; @@ -33,12 +42,24 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy { availableRegionConfigs: RegionConfig[] = this.environmentService.availableRegions(); + private selectedRegionFromEnv: RegionConfig | Region.SelfHosted; + + isDesktopOrBrowserExtension = false; + private destroy$ = new Subject(); constructor( private formBuilder: FormBuilder, private environmentService: EnvironmentService, - ) {} + private dialogService: DialogService, + private i18nService: I18nService, + private toastService: ToastService, + private platformUtilsService: PlatformUtilsService, + ) { + const clientType = platformUtilsService.getClientType(); + this.isDesktopOrBrowserExtension = + clientType === ClientType.Desktop || clientType === ClientType.Browser; + } async ngOnInit() { await this.initSelectedRegionAndListenForEnvChanges(); @@ -61,13 +82,17 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy { return regionConfig; }), - tap((selectedRegionInitialValue: RegionConfig | Region.SelfHosted) => { - // This inits the form control with the selected region, but - // it also sets the value to self hosted if the self hosted settings are saved successfully - // in the client specific implementation managed by the parent component. - // It also resets the value to the previously selected region if the self hosted - // settings are closed without saving. We don't emit the event to avoid a loop. - this.selectedRegion.setValue(selectedRegionInitialValue, { emitEvent: false }); + tap((selectedRegionFromEnv: RegionConfig | Region.SelfHosted) => { + // Only set the value if it is different from the current value. + if (selectedRegionFromEnv !== this.selectedRegion.value) { + // Don't emit to avoid triggering the selectedRegion valueChanges subscription + // which could loop back to this code. + this.selectedRegion.setValue(selectedRegionFromEnv, { emitEvent: false }); + } + + // Save this off so we can reset the value to the previously selected region + // if the self hosted settings are closed without saving. + this.selectedRegionFromEnv = selectedRegionFromEnv; }), takeUntil(this.destroy$), ) @@ -77,23 +102,66 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy { private listenForSelectedRegionChanges() { this.selectedRegion.valueChanges .pipe( - switchMap((selectedRegionConfig: RegionConfig | Region.SelfHosted | null) => { - if (selectedRegionConfig === null) { - return of(null); - } - - if (selectedRegionConfig === Region.SelfHosted) { - this.onOpenSelfHostedSettings.emit(); - return EMPTY; - } - - return from(this.environmentService.setEnvironment(selectedRegionConfig.key)); - }), + startWith(null), // required so that first user choice is not ignored + pairwise(), + switchMap( + ([prevSelectedRegion, selectedRegion]: [ + RegionConfig | Region.SelfHosted | null, + RegionConfig | Region.SelfHosted | null, + ]) => { + if (selectedRegion === null) { + this.selectedRegionChange.emit(selectedRegion); + return of(null); + } + + if (selectedRegion === Region.SelfHosted) { + return from( + RegistrationSelfHostedEnvConfigDialogComponent.open(this.dialogService), + ).pipe( + tap((result: boolean | undefined) => + this.handleSelfHostedEnvConfigDialogResult(result, prevSelectedRegion), + ), + ); + } + + this.selectedRegionChange.emit(selectedRegion); + return from(this.environmentService.setEnvironment(selectedRegion.key)); + }, + ), takeUntil(this.destroy$), ) .subscribe(); } + private handleSelfHostedEnvConfigDialogResult( + result: boolean | undefined, + prevSelectedRegion: RegionConfig | Region.SelfHosted | null, + ) { + if (result === true) { + this.selectedRegionChange.emit(Region.SelfHosted); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("environmentSaved"), + }); + return; + } + + // Reset the value to the previously selected region or the current env setting + // if the self hosted env settings dialog is closed without saving. + if ( + (result === false || result === undefined) && + prevSelectedRegion !== null && + prevSelectedRegion !== Region.SelfHosted + ) { + this.selectedRegionChange.emit(prevSelectedRegion); + this.selectedRegion.setValue(prevSelectedRegion, { emitEvent: false }); + } else { + this.selectedRegionChange.emit(this.selectedRegionFromEnv); + this.selectedRegion.setValue(this.selectedRegionFromEnv, { emitEvent: false }); + } + } + ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-self-hosted-env-config-dialog.component.html b/libs/auth/src/angular/registration/registration-env-selector/registration-self-hosted-env-config-dialog.component.html new file mode 100644 index 000000000000..92c2f9f2f7ab --- /dev/null +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-self-hosted-env-config-dialog.component.html @@ -0,0 +1,107 @@ +
    + + Self-hosted environment + + + {{ "baseUrl" | i18n }} + + {{ "selfHostedBaseUrlHint" | i18n }} + + + + + +

    + {{ "selfHostedCustomEnvHeader" | i18n }} +

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