diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d1266a174e4..3884bfda063 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -84,6 +84,7 @@ apps/web/src/app/billing @bitwarden/team-billing-dev libs/angular/src/billing @bitwarden/team-billing-dev libs/common/src/billing @bitwarden/team-billing-dev libs/billing @bitwarden/team-billing-dev +libs/pricing @bitwarden/team-billing-dev bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev ## Platform team files ## @@ -227,7 +228,9 @@ apps/web/src/locales/en/messages.json **/tsconfig.json @bitwarden/team-platform-dev **/jest.config.js @bitwarden/team-platform-dev **/project.jsons @bitwarden/team-platform-dev -libs/pricing @bitwarden/team-billing-dev +# Platform override specifically for the package-lock.json in +# native-messaging-test-runner so that Platform can manage all lock file updates +apps/desktop/native-messaging-test-runner/package-lock.json @bitwarden/team-platform-dev # Claude related files .claude/ @bitwarden/team-ai-sme diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 7614fdba396..7b35baf01e2 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -565,7 +565,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 701e6208b60..0d4009e54f9 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1007,7 +1007,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.14.2' @@ -1247,7 +1247,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.14.2' @@ -1522,7 +1522,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.14.2' @@ -1873,7 +1873,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index e626b629f5c..7b92de0f22a 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -204,7 +204,7 @@ jobs: ########## Set up Docker ########## - name: Set up Docker - uses: docker/setup-docker-action@efe9e3891a4f7307e689f2100b33a155b900a608 # v4.5.0 + uses: docker/setup-docker-action@e43656e248c0bd0647d3f5c195d116aacf6fcaf4 # v4.7.0 with: daemon-config: | { @@ -218,7 +218,7 @@ jobs: uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 ########## ACRs ########## - name: Log in to Azure @@ -334,7 +334,7 @@ jobs: - name: Scan Docker image if: ${{ needs.setup.outputs.has_secrets == 'true' }} id: container-scan - uses: anchore/scan-action@568b89d27fc18c60e56937bff480c91c772cd993 # v7.1.0 + uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3 with: image: ${{ steps.image-name.outputs.name }} fail-build: false @@ -390,7 +390,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index e99034c499a..a707fef0889 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -49,7 +49,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/lint-crowdin-config.yml b/.github/workflows/lint-crowdin-config.yml index dff253a8da2..61e2b3631e6 100644 --- a/.github/workflows/lint-crowdin-config.yml +++ b/.github/workflows/lint-crowdin-config.yml @@ -45,7 +45,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Lint ${{ matrix.app.name }} config - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_PROJECT_ID: ${{ matrix.app.project_id }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 83c931b4fe0..6a5f6774474 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -142,7 +142,7 @@ jobs: run: cargo +nightly udeps --workspace --all-features --all-targets - name: Install cargo-deny - uses: taiki-e/install-action@073d46cba2cde38f6698c798566c1b3e24feeb44 # v2.62.67 + uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2.66.7 with: tool: cargo-deny@0.18.6 diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index f013abbbb3b..c5db7ea9295 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -331,7 +331,7 @@ jobs: run: wget "https://github.com/bitwarden/clients/releases/download/${_RELEASE_TAG}/macos-build-number.json" - name: Setup Ruby and Install Fastlane - uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 + uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # v1.283.0 with: ruby-version: '3.4.7' bundler-cache: false diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index be0087800f7..c45e249d083 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -182,7 +182,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 79f3335313e..33b4df24d7a 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -72,7 +72,6 @@ jobs: permissions: id-token: write contents: write - pull-requests: write steps: - name: Validate version input format @@ -106,13 +105,12 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - permission-contents: write # for creating, committing to, and pushing new branches - permission-pull-requests: write # for generating pull requests + permission-contents: write # for committing and pushing to main (bypasses rulesets) - name: Check out branch uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 @@ -448,53 +446,15 @@ jobs: echo "No changes to commit!"; fi - - name: Create version bump branch - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - run: | - BRANCH_NAME="version-bump-$(date +%s)" - git checkout -b "$BRANCH_NAME" - echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV - - name: Commit version bumps with GPG signature if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} run: | git commit -m "Bumped client version(s)" -a - - name: Push version bump branch + - name: Push changes to main if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} run: | - git push --set-upstream origin "$BRANCH_NAME" - - - name: Create Pull Request for version bump - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - env: - VERSION_BROWSER: ${{ steps.set-final-version-output.outputs.version_browser }} - VERSION_CLI: ${{ steps.set-final-version-output.outputs.version_cli }} - VERSION_DESKTOP: ${{ steps.set-final-version-output.outputs.version_desktop }} - VERSION_WEB: ${{ steps.set-final-version-output.outputs.version_web }} - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const versions = []; - if (process.env.VERSION_BROWSER) versions.push(`- Browser: ${process.env.VERSION_BROWSER}`); - if (process.env.VERSION_CLI) versions.push(`- CLI: ${process.env.VERSION_CLI}`); - if (process.env.VERSION_DESKTOP) versions.push(`- Desktop: ${process.env.VERSION_DESKTOP}`); - if (process.env.VERSION_WEB) versions.push(`- Web: ${process.env.VERSION_WEB}`); - - const body = versions.length > 0 - ? `Automated version bump:\n\n${versions.join('\n')}` - : 'Automated version bump'; - - const { data: pr } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: 'Bumped client version(s)', - body: body, - head: process.env.BRANCH_NAME, - base: context.ref.replace('refs/heads/', '') - }); - console.log(`Created PR #${pr.number}: ${pr.html_url}`); + git push cut_branch: name: Cut branch @@ -525,7 +485,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/sdk-breaking-change-check.yml b/.github/workflows/sdk-breaking-change-check.yml index ecc803ebd5c..765e900af5c 100644 --- a/.github/workflows/sdk-breaking-change-check.yml +++ b/.github/workflows/sdk-breaking-change-check.yml @@ -53,7 +53,7 @@ jobs: secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4280cabc812..eedf991d826 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,7 +62,7 @@ jobs: run: npm test -- --coverage --maxWorkers=3 - name: Report test results - uses: dorny/test-reporter@7b7927aa7da8b82e81e755810cb51f39941a2cc7 # v2.2.0 + uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results @@ -71,7 +71,9 @@ jobs: fail-on-error: true - name: Upload results to codecov.io - uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + with: + report_type: test_results - name: Upload test coverage uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index d66c48fcf58..2aba68c45a9 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -31,7 +31,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/apps/browser/package.json b/apps/browser/package.json index 7055aabf4fd..745c9d6f3e3 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2025.12.1", + "version": "2026.1.0", "scripts": { "build": "npm run build:chrome", "build:bit": "npm run build:bit:chrome", diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 3b9ca37f6b4..937672bfd60 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "تسجيل الدخول باستخدام مفتاح المرور" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "استخدام تسجيل الدخول الأحادي" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "تم حذف العنصر بشكل دائم" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "استعادة العنصر" }, @@ -3364,6 +3370,12 @@ "error": { "message": "خطأ" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "خطأ فك التشفير" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 5eeedc430b1..58c9b5a0cb8 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Keçid açarı ilə giriş et" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Vahid daxil olma üsulunu istifadə et" }, @@ -582,14 +585,14 @@ "archiveItem": { "message": "Elementi arxivlə" }, - "archiveItemConfirmDesc": { - "message": "Arxivlənmiş elementlər ümumi axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək. Bu elementi arxivləmək istədiyinizə əminsiniz?" + "archiveItemDialogContent": { + "message": "Arxivləndikdən sonra, bu element axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək." }, "archived": { - "message": "Archived" + "message": "Arxivləndi" }, "unarchiveAndSave": { - "message": "Unarchive and save" + "message": "Arxivdən çıxart və saxla" }, "upgradeToUseArchive": { "message": "Arxivi istifadə etmək üçün premium üzvlük tələb olunur." @@ -1546,13 +1549,13 @@ "message": "YubiKey və Duo kimi mülkiyyətçi iki addımlı giriş seçimləri." }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Premium abunəliyiniz bitdi" }, "archivePremiumRestart": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + "message": "Arxivinizə təkrar erişmək üçün Premium abunəliyinizi yenidən başladın. Təkrar başlatmazdan əvvəl arxivlənmiş elementin detallarına düzəliş etsəniz, həmin element seyfinizə daşınacaq." }, "restartPremium": { - "message": "Restart Premium" + "message": "\"Premium\"u yenidən başlat" }, "ppremiumSignUpReports": { "message": "Seyfinizi güvəndə saxlamaq üçün parol gigiyenası, hesab sağlamlığı və veri pozuntusu hesabatları." @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Element birdəfəlik silindi" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Elementi bərpa et" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Xəta" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Şifrə açma xətası" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Daha çox seçim" + }, "moreOptionsTitle": { "message": "Daha çox seçim - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "$WEBSITE$ ilə uyuşma aşkarlamasını gizlət", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Uyuşma aşkarlamasını göstər" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Uyuşma aşkarlamasını gizlət" }, "autoFillOnPageLoad": { "message": "Səhifə yüklənəndə avto-doldurulsun?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Ekstra enli" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Daxil etdiyiniz parol yanlışdır." }, diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 96042f12e19..68277cfeb00 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Увайсці з ключом доступу" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Выкарыстаць аднаразовы ўваход" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Элемент выдалены назаўсёды" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Аднавіць элемент" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Памылка" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 9a420b1d177..05ee1fc5765 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Вписване със секретен ключ" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Използване на еднократна идентификация" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Архивиране на елемента" }, - "archiveItemConfirmDesc": { - "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" + "archiveItemDialogContent": { + "message": "След като бъде архивиран, този елемент няма да се показва в резултатите при търсене, нито в предложенията за автоматично попълване." }, "archived": { "message": "Архивирано" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Записът е изтрит окончателно" }, + "archivedItemRestored": { + "message": "Архивираният елемент е възстановен" + }, "restoreItem": { "message": "Възстановяване на запис" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Грешка" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Грешка при дешифриране" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Още настройки" + }, "moreOptionsTitle": { "message": "Още опции – $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Скриване на откритото съвпадение $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Показване на откриването на съвпадения" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Скриване на откриването на съвпадения" }, "autoFillOnPageLoad": { "message": "Автоматично попълване при зареждане на страницата?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Много широко" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Въведената парола е неправилна." }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 78b55611b50..fa4d93fa9ee 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "বস্তুটি স্থায়ীভাবে মুছে ফেলা হয়েছে" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "বস্তু পুনরুদ্ধার" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index 3bd578aa2a3..7eb327b034a 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 97b9911536a..3a9333b5471 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Inicieu sessió amb la clau de pas" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Inici de sessió únic" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Element suprimit definitivament" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restaura l'element" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Error de desxifrat" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Més opcions - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Habilita l'emplenament automàtic en carregar la pàgina?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra ample" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 4c88374ed3e..46618df6257 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Přihlásit se pomocí přístupového klíče" }, + "unlockWithPasskey": { + "message": "Odemknout pomocí přístupového klíče" + }, "useSingleSignOn": { "message": "Použít jednotné přihlášení" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archivovat položku" }, - "archiveItemConfirmDesc": { - "message": "Archivované položky jsou vyloučeny z obecných výsledků vyhledávání a z návrhů automatického vyplňování. Jste si jisti, že chcete tuto položku archivovat?" + "archiveItemDialogContent": { + "message": "Jakmile bude tato položka archivována, bude vyloučena z výsledků vyhledávání a z návrhů automatického vyplňování." }, "archived": { "message": "Archivováno" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Položka byla trvale smazána" }, + "archivedItemRestored": { + "message": "Archivovaná položka byla obnovena" + }, "restoreItem": { "message": "Obnovit položku" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Chyba" }, + "prfUnlockFailed": { + "message": "Nepodařilo se odemknout pomocí přístupového klíče. Zkuste to znovu nebo použijte jinou metodu odemknutí." + }, + "noPrfCredentialsAvailable": { + "message": "K odemknutí nejsou k dispozici žádné přístupové klíče s podporou PRF. Nejprve se přihlaste pomocí hesla." + }, "decryptionError": { "message": "Chyba dešifrování" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Více voleb" + }, "moreOptionsTitle": { "message": "Více voleb - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Skrýt detekci shody $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Zobrazit detekci shody" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Skrýt detekci shody" }, "autoFillOnPageLoad": { "message": "Automaticky vyplnit při načtení stránky?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra široké" }, + "narrow": { + "message": "Úzké" + }, "sshKeyWrongPassword": { "message": "Zadané heslo není správné." }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 64a240add67..d765b7d8a10 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Eitem wedi'i dileu'n barhaol" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Adfer yr eitem" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Gwall" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Llydan iawn" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 640dbaf89b7..5add4d4b10c 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log ind med adgangsnøgle" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Brug Single Sign-On" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Element slettet permanent" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Gendan element" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Fejl" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Dekrypteringsfejl" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Flere valgmuligheder - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Skjul matchdetektion $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autoudfyld ved sideindlæsning?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Ekstra bred" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index ce72944943c..8579ebdee3e 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Mit Passkey anmelden" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Single Sign-On verwenden" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Eintrag archivieren" }, - "archiveItemConfirmDesc": { - "message": "Archivierte Einträge werden von allgemeinen Suchergebnissen sowie Vorschlägen zum automatischen Ausfüllen ausgeschlossen. Bist du sicher, dass du diesen Eintrag archivieren möchtest?" + "archiveItemDialogContent": { + "message": "Nach der Archivierung wird dieser Eintrag aus den Suchergebnissen und Auto-Ausfüllen-Vorschlägen ausgeschlossen." }, "archived": { "message": "Archiviert" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Eintrag dauerhaft gelöscht" }, + "archivedItemRestored": { + "message": "Archivierter Eintrag wiederhergestellt" + }, "restoreItem": { "message": "Eintrag wiederherstellen" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Fehler" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Entschlüsselungsfehler" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Weitere Optionen" + }, "moreOptionsTitle": { "message": "Weitere Optionen - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Übereinstimmungs-Erkennung verstecken $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Übereinstimmungserkennung anzeigen" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Übereinstimmungserkennung ausblenden" }, "autoFillOnPageLoad": { "message": "Auto-Ausfüllen beim Laden einer Seite?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra breit" }, + "narrow": { + "message": "Schmal" + }, "sshKeyWrongPassword": { "message": "Dein eingegebenes Passwort ist falsch." }, diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index f20edf63793..d1eebc0362c 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Σύνδεση με κλειδί πρόσβασης" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Χρήση ενιαίας σύνδεσης" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Το αντικείμενο διαγράφηκε οριστικά" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Επαναφορά αντικειμένου" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Σφάλμα" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Σφάλμα αποκρυπτογράφησης" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Περισσότερες επιλογές - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Απόκρυψη ανιχνεύσεων αντιστοίχισης $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Αυτόματη συμπλήρωση κατά τη φόρτωση της σελίδας;" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Εξαιρετικά φαρδύ" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index dabd238e039..8e2c3279687 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5001,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 4fd70672f4a..68cf36cacde 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index a92aac915c1..216db1911f2 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Permanently deleted item" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index f72635696a8..6eca24db96e 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Iniciar sesión con clave de acceso" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Usar inicio de sesión único" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Elemento eliminado de forma permanente" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restaurar elemento" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Error de descifrado" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Más opciones - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "¿Autocompletar al cargar la página?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extraancho" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "La contraseña introducida es incorrecta." }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index a7730b536f7..72f9c553569 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Logi sisse pääsuvõtmega" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Kirje on jäädavalt kustutatud" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Taasta kirje" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Viga" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index 7a6ca24b3da..04e673d2230 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Elementua betirako ezabatua" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Berreskuratu elementua" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Akatsa" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index c5af707763f..a3ea290de39 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "با کلید عبور وارد شوید" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "استفاده از ورود تک مرحله‌ای" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "مورد برای همیشه حذف شد" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "بازیابی مورد" }, @@ -3364,6 +3370,12 @@ "error": { "message": "خطا" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "خطای رمزگشایی" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "گزینه‌های بیشتر - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "مخفی کردن شناسایی تطابق $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "پر کردن خودکار هنگام بارگذاری صفحه؟" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "خیلی عریض" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "کلمه عبور وارد شده اشتباه است." }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index cfab00e0849..0e19e256714 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Kirjaudu pääsyavaimella" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Käytä kertakirjautumista" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Arkistoi kohde" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Kohde poistettiin pysyvästi" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Palauta kohde" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Virhe" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Salauksen purkuvirhe" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Lisää valintoja - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Piilota vastaavuuden tunnistus $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Automaattitäytetäänkö sivun avautuessa?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Erittäin leveä" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Syöttämäsi salasana on virheellinen." }, diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 91fe7f70ced..b44f5210ccd 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item permanenteng tinanggal" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Ibalik ang item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Mali" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index e884c3cd141..6b5348f564f 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Se connecter avec une clé d'accès" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Utiliser l'authentification unique" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archiver l'élément" }, - "archiveItemConfirmDesc": { - "message": "Les éléments archivés sont exclus des résultats de recherche généraux et des suggestions de remplissage automatique. Êtes-vous sûr de vouloir archiver cet élément ?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Élément définitivement supprimé" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restaurer l'élément" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Erreur" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Erreur de déchiffrement" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Plus d'options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Masquer la détection de correspondance $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Saisir automatiquement lors du chargement de la page ?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Très large" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Le mot de passe saisi est incorrect." }, diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 27c66bb1ce6..faf9faf755d 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Iniciar sesión con Clave de acceso" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Usar inicio de sesión único" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Entrada eliminada permanente" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restaurar entrada" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Erro" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Erro de descifrado" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Máis opcións - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Agochar detección de coincidencia $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autoencher ó cargar a páxina?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Moi ancho" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 8c09d562f60..3d953f508a1 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "כניסה עם מפתח גישה" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "השתמש בכניסה יחידה" }, @@ -437,7 +440,7 @@ "message": "סנכרן" }, "syncNow": { - "message": "Sync now" + "message": "סנכרון כעת" }, "lastSync": { "message": "סנכרון אחרון:" @@ -574,7 +577,7 @@ "message": "הפריט נשלח לארכיון" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "הפריט שוחזר מהארכיב" }, "itemUnarchived": { "message": "הפריט הוסר מהארכיון" @@ -582,20 +585,20 @@ "archiveItem": { "message": "העבר פריט לארכיון" }, - "archiveItemConfirmDesc": { - "message": "פריטים בארכיון מוחרגים מתוצאות חיפוש כללי והצעות למילוי אוטומטי. האם אתה בטוח שברצונך להעביר פריט זה לארכיון?" + "archiveItemDialogContent": { + "message": "עם ארכובו, יהיה הפריט מוחרג מתוצאות החיפוש ומהצעות המילוי האוטומטי." }, "archived": { - "message": "Archived" + "message": "הועבר לארכיב" }, "unarchiveAndSave": { - "message": "Unarchive and save" + "message": "שחזור מהארכיב ושמירה" }, "upgradeToUseArchive": { - "message": "A premium membership is required to use Archive." + "message": "נדרשת חברות פרמיום כדי להשתמש בארכיב." }, "itemRestored": { - "message": "Item has been restored" + "message": "הפריט שוחזר" }, "edit": { "message": "ערוך" @@ -607,7 +610,7 @@ "message": "הצג הכל" }, "showAll": { - "message": "Show all" + "message": "הצגת הכל" }, "viewLess": { "message": "הצג פחות" @@ -1335,19 +1338,19 @@ "message": "ייצא מ־" }, "exportVerb": { - "message": "Export", + "message": "ייצוא", "description": "The verb form of the word Export" }, "exportNoun": { - "message": "Export", + "message": "ייצוא", "description": "The noun form of the word Export" }, "importNoun": { - "message": "Import", + "message": "ייבוא", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "ייבוא", "description": "The verb form of the word Import" }, "fileFormat": { @@ -1429,25 +1432,25 @@ "message": "למידע נוסף" }, "migrationsFailed": { - "message": "An error occurred updating the encryption settings." + "message": "אירעה שגיאה בעת עדכון הגדרות ההצפנה." }, "updateEncryptionSettingsTitle": { - "message": "Update your encryption settings" + "message": "עדכון הגדרות ההצפנה שלך" }, "updateEncryptionSettingsDesc": { - "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + "message": "הגדרות ההצפנה המומלצות החדשות ישפרו את אבטחת החשבון שלך. יש להזין את הסיסמה הראשית שלך כדי לעדכן כעת." }, "confirmIdentityToContinue": { - "message": "Confirm your identity to continue" + "message": "יש לאשר את זהותך כדי להמשיך" }, "enterYourMasterPassword": { - "message": "Enter your master password" + "message": "נא להזין את הסיסמה הראשית שלך" }, "updateSettings": { - "message": "Update settings" + "message": "עדכון ההגדרות" }, "later": { - "message": "Later" + "message": "מאוחר יותר" }, "authenticatorKeyTotp": { "message": "מפתח מאמת (TOTP)" @@ -1480,13 +1483,13 @@ "message": "הצרופה נשמרה" }, "fixEncryption": { - "message": "Fix encryption" + "message": "תיקון ההצפנה" }, "fixEncryptionTooltip": { - "message": "This file is using an outdated encryption method." + "message": "הקובץ מוגדר בשיטת הצפנה לא עדכנית." }, "attachmentUpdated": { - "message": "Attachment updated" + "message": "הצרופה עודכנה" }, "file": { "message": "קובץ" @@ -1498,7 +1501,7 @@ "message": "בחר קובץ" }, "itemsTransferred": { - "message": "Items transferred" + "message": "הפריטים הועברו" }, "maxFileSize": { "message": "גודל הקובץ המרבי הוא 500MB." @@ -1531,7 +1534,7 @@ "message": "1 ג'יגה של מקום אחסון עבור קבצים מצורפים." }, "premiumSignUpStorageV2": { - "message": "$SIZE$ encrypted storage for file attachments.", + "message": "$SIZE$ של אחסון מוצפן עבור קבצים מצורפים.", "placeholders": { "size": { "content": "$1", @@ -1546,13 +1549,13 @@ "message": "אפשרויות כניסה דו־שלבית קנייניות כגון YubiKey ו־Duo." }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "מנוי הפרמיום שלך הסתיים" }, "archivePremiumRestart": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + "message": "לשחזור הגישה לארכיב שלך יש לחדש את מנוי הפרמיום שלך. אם תבצעו עריכת פרטים של פריט בארכיב לפני חידוש המנוי, הפריט ישוחזר אל הכספת שלכם." }, "restartPremium": { - "message": "Restart Premium" + "message": "חידוש מנוי הפרמיום" }, "ppremiumSignUpReports": { "message": "היגיינת סיסמאות, מצב בריאות החשבון, ודיווחים מעודכנים על פרצות חדשות בכדי לשמור על הכספת שלך בטוחה." @@ -1947,7 +1950,7 @@ "message": "שנת תפוגה" }, "monthly": { - "message": "month" + "message": "חודש" }, "expiration": { "message": "תוקף" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "הפריט נמחק לצמיתות" }, + "archivedItemRestored": { + "message": "פריט שוחזר מהארכיב" + }, "restoreItem": { "message": "שחזר פריט" }, @@ -3364,6 +3370,12 @@ "error": { "message": "שגיאה" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "שגיאת פענוח" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "עוד אפשרויות - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "הסתר זיהוי התאמה $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "למלא אוטומטית בעת טעינת עמוד?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "רחב במיוחד" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "הסיסמה שהזנת שגויה." }, diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 50fc056527a..ea0eb362a0d 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "सिंगल साइन-ऑन प्रयोग करें" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "स्थायी रूप से आइटम हटाएं" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "आइटम बहाल करें" }, @@ -3364,6 +3370,12 @@ "error": { "message": "एरर" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 3e94ef40a30..b7dbed3dcc0 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Prijava pristupnim ključem" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Jedinstvena prijava (SSO)" }, @@ -437,7 +440,7 @@ "message": "Sinkronizacija" }, "syncNow": { - "message": "Sync now" + "message": "Sinkroniziraj" }, "lastSync": { "message": "Posljednja sinkronizacija:" @@ -574,7 +577,7 @@ "message": "Stavka poslana u arhivu" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "Stavka vraćena iz arhive" }, "itemUnarchived": { "message": "Stavka vraćena iz arhive" @@ -582,11 +585,11 @@ "archiveItem": { "message": "Arhiviraj stavku" }, - "archiveItemConfirmDesc": { - "message": "Arhivirane stavke biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune. Sigurno želiš arhivirati?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { - "message": "Archived" + "message": "Arhivirano" }, "unarchiveAndSave": { "message": "Unarchive and save" @@ -595,7 +598,7 @@ "message": "A premium membership is required to use Archive." }, "itemRestored": { - "message": "Item has been restored" + "message": "Stavka je vraćena" }, "edit": { "message": "Uredi" @@ -607,7 +610,7 @@ "message": "Vidi sve" }, "showAll": { - "message": "Show all" + "message": "Prikaži sve" }, "viewLess": { "message": "Vidi manje" @@ -1335,19 +1338,19 @@ "message": "Izvezi iz" }, "exportVerb": { - "message": "Export", + "message": "Izvoz", "description": "The verb form of the word Export" }, "exportNoun": { - "message": "Export", + "message": "Izvoz", "description": "The noun form of the word Export" }, "importNoun": { - "message": "Import", + "message": "Uvoz", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Uvoz", "description": "The verb form of the word Import" }, "fileFormat": { @@ -1429,25 +1432,25 @@ "message": "Saznaj više" }, "migrationsFailed": { - "message": "An error occurred updating the encryption settings." + "message": "Dogodila se greška pri ažuriranju postavki šifriranja." }, "updateEncryptionSettingsTitle": { - "message": "Update your encryption settings" + "message": "Ažuriraj svoje postakve šifriranja" }, "updateEncryptionSettingsDesc": { - "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + "message": "Nove preporučene postavke šifriranja poboljšat će sigurnost tvojeg računa. Za ažuriranje, unesi svoju glavnu lozinku." }, "confirmIdentityToContinue": { - "message": "Confirm your identity to continue" + "message": "Za nastavak, potvrdi svoj identitet" }, "enterYourMasterPassword": { - "message": "Enter your master password" + "message": "Unesi svoju glavnu lozinku" }, "updateSettings": { - "message": "Update settings" + "message": "Ažuriraj postavke" }, "later": { - "message": "Later" + "message": "Kasnije" }, "authenticatorKeyTotp": { "message": "Ključ autentifikatora (TOTP)" @@ -1486,7 +1489,7 @@ "message": "This file is using an outdated encryption method." }, "attachmentUpdated": { - "message": "Attachment updated" + "message": "Privitak ažuriran" }, "file": { "message": "Datoteka" @@ -1531,7 +1534,7 @@ "message": "1 GB šifriranog prostora za pohranu podataka." }, "premiumSignUpStorageV2": { - "message": "$SIZE$ encrypted storage for file attachments.", + "message": "$SIZE$ šifriranog prostora za privitke.", "placeholders": { "size": { "content": "$1", @@ -1546,13 +1549,13 @@ "message": "Mogućnosti za prijavu u dva koraka kao što su YubiKey i Duo." }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Toja Premium pretplata je završila" }, "archivePremiumRestart": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + "message": "Za ponovni pristup svojoj arhivi, ponovno pokreni Premium pretplatu. Ako urediš detalje arhivirane stavke prije ponovnog pokretanja, ona će biti vraćena u tvoj trezor." }, "restartPremium": { - "message": "Restart Premium" + "message": "Ponovno Pokreni Premium" }, "ppremiumSignUpReports": { "message": "Higijenu lozinki, zdravlje računa i izvještaje o krađi podatak radi zaštite svojeg trezora." @@ -1947,7 +1950,7 @@ "message": "Godina isteka" }, "monthly": { - "message": "month" + "message": "mjesečno" }, "expiration": { "message": "Istek" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Stavka trajno izbrisana" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Vrati stavku" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Pogreška" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Pogreška pri dešifriranju" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Više mogućnosti - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4860,7 +4875,7 @@ "message": "Turned on automatic confirmation" }, "availableNow": { - "message": "Available now" + "message": "Dostupno sada" }, "accountSecurity": { "message": "Sigurnost računa" @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Sakrij otkrivanje podudaranja $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Auto-ispuna kod učitavanja?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Ekstra široko" }, + "narrow": { + "message": "Usko" + }, "sshKeyWrongPassword": { "message": "Unesena lozinka nije ispravna." }, @@ -5719,10 +5734,10 @@ "message": "Ova prijava je ugrožena i nedostaje joj web-stranica. Dodaj web-stranicu i promijeni lozinku za veću sigurnost." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Ranjiva lozinka." }, "changeNow": { - "message": "Change now" + "message": "Promijeni sada" }, "missingWebsite": { "message": "Nedostaje web-stranica" @@ -6092,13 +6107,13 @@ } }, "acceptTransfer": { - "message": "Accept transfer" + "message": "Prihvati prijenos" }, "declineAndLeave": { - "message": "Decline and leave" + "message": "Odbij i napusti" }, "whyAmISeeingThis": { - "message": "Why am I seeing this?" + "message": "Zašto ovo vidim?" }, "resizeSideNavigation": { "message": "Resize side navigation" diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 3977f7a4fb5..fb9e327337c 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Bejelentkezés hozzáférési kulccsal" }, + "unlockWithPasskey": { + "message": "Hozzáférési kulcs" + }, "useSingleSignOn": { "message": "Egyszeri bejelentkezés használata" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Elem archiválása" }, - "archiveItemConfirmDesc": { - "message": "Az archivált elemek ki vannak zárva az általános keresési eredményekből és az automatikus kitöltési javaslatokból. Biztosan archiválni szeretnénk ezt az elemet?" + "archiveItemDialogContent": { + "message": "Az archiválás után ez az elem kizárásra kerül a keresési eredményekből és az automatikus kitöltési javaslatokból." }, "archived": { "message": "Archiválva" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Véglegesen törölt elem" }, + "archivedItemRestored": { + "message": "Az archivált elem visszaállításra került." + }, "restoreItem": { "message": "Elem visszaállítása" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Hiba" }, + "prfUnlockFailed": { + "message": "Nem sikerült a feloldás a hozzéférési kulccsal. Próbáljuk újra vagy használjunk más feloldási metódust." + }, + "noPrfCredentialsAvailable": { + "message": "A feloldáshoz nem állnak rendelkezésre PRF kompatibilis hozzáférési kucsok. Először jelentkezzünk be egy hozzáférési kulccsal." + }, "decryptionError": { "message": "Visszafejtési hiba" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "További opciók" + }, "moreOptionsTitle": { "message": "További opciók - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "$WEBSITE$ egyező érzékelés elrejtése", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Egyezés felismerés megjelenítése" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Egyezés felismerés elrejtése" }, "autoFillOnPageLoad": { "message": "Automatikus kitöltés oldalbetöltésnél?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra széles" }, + "narrow": { + "message": "Keskeny" + }, "sshKeyWrongPassword": { "message": "A megadott jelszó helytelen." }, diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 08d346d57d8..064e67eb76f 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Masuk dengan kunci sandi" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Gunakan masuk tunggal" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -1947,7 +1950,7 @@ "message": "Tahun Kedaluwarsa" }, "monthly": { - "message": "month" + "message": "bulan" }, "expiration": { "message": "Masa Berlaku" @@ -2097,51 +2100,51 @@ "message": "Catatan" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Log Masuk Baru", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Kartu Baru", "description": "Header for new card item type" }, "newItemHeaderIdentity": { - "message": "New Identity", + "message": "Identitas Baru", "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Catatan Baru", "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Kunci SSH Baru", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Kirim Teks Baru", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Kirim Berkas Baru", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Sunting Log Masuk", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Sunting Kartu", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { - "message": "Edit Identity", + "message": "Sunting Identitas", "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Sunting Catatan", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Sunting kunci SSH", "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Hapus Item Secara Permanen" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Pulihkan Item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Galat" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Kesalahan dekripsi" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Pilihan lainnya - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Sembunyikan deteksi kecocokan $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Isi otomatis ketika halaman dimuat?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Ekstra lebar" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Kata sandi yang Anda masukkan tidak benar." }, @@ -5994,19 +6009,19 @@ "message": "Leave now" }, "verifyYourDomainToLogin": { - "message": "Verify your domain to log in" + "message": "Verifikasikan domain Anda untuk log masuk" }, "verifyYourDomainDescription": { - "message": "To continue with log in, verify this domain." + "message": "Untuk melanjutkan log masuk, verifikasikan domain ini." }, "confirmKeyConnectorOrganizationUserDescription": { - "message": "To continue with log in, verify the organization and domain." + "message": "Untuk melanjutkan log masuk, verifikasikan organisasi dan domain." }, "sessionTimeoutSettingsAction": { - "message": "Timeout action" + "message": "Tindakan saat habis waktu" }, "sessionTimeoutSettingsManagedByOrganization": { - "message": "This setting is managed by your organization." + "message": "Pengaturan ini dikelola oleh organisasi Anda." }, "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": { "message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 32fe8e9ce54..3e47b38f141 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Accedi con passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Usa il Single Sign-On" }, @@ -582,14 +585,14 @@ "archiveItem": { "message": "Archivia elemento" }, - "archiveItemConfirmDesc": { - "message": "Gli elementi archiviati sono esclusi dai risultati di ricerca e suggerimenti di autoriempimento. Vuoi davvero archiviare questo elemento?" + "archiveItemDialogContent": { + "message": "Una volta archiviato, questo elemento sarà escluso dai risultati di ricerca e dai suggerimenti del completamento automatico." }, "archived": { - "message": "Archived" + "message": "Archiviato" }, "unarchiveAndSave": { - "message": "Unarchive and save" + "message": "Togli dall'archivio e salva" }, "upgradeToUseArchive": { "message": "Per utilizzare Archivio è necessario un abbonamento premium." @@ -1546,13 +1549,13 @@ "message": "Opzioni di verifica in due passaggi proprietarie come YubiKey e Duo." }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Il tuo abbonamento Premium è terminato" }, "archivePremiumRestart": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + "message": "Per recuperare l'accesso al tuo archivio, riavvia il tuo abbonamento Premium. Se modifichi i dettagli di un elemento archiviato prima del riavvio, sarà spostato nella tua cassaforte." }, "restartPremium": { - "message": "Restart Premium" + "message": "Riavvia Premium" }, "ppremiumSignUpReports": { "message": "Sicurezza delle password, integrità dell'account, e rapporti su violazioni di dati per mantenere sicura la tua cassaforte." @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Elemento eliminato definitivamente" }, + "archivedItemRestored": { + "message": "Elemento estratto dall'archivio" + }, "restoreItem": { "message": "Ripristina elemento" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Errore" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Errore di decifrazione" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Altre opzioni" + }, "moreOptionsTitle": { "message": "Più opzioni - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Nascondi corrispondenza $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Mostra il criterio di corrispondenza dell'URL" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Nascondi il criterio di corrispondenza dell'URL" }, "autoFillOnPageLoad": { "message": "Riempi automaticamente al caricamento della pagina?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Molto larga" }, + "narrow": { + "message": "Stretto" + }, "sshKeyWrongPassword": { "message": "La parola d'accesso inserita non è corretta." }, diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 0abea0e0236..9784ad44f2a 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "パスキーでログイン" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "シングルサインオンを使用する" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "アイテムをアーカイブ" }, - "archiveItemConfirmDesc": { - "message": "アーカイブされたアイテムはここに表示され、通常の検索結果および自動入力の候補から除外されます。このアイテムをアーカイブしますか?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "完全に削除されたアイテム" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "アイテムをリストア" }, @@ -3364,6 +3370,12 @@ "error": { "message": "エラー" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "復号エラー" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "その他のオプション - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "一致検出 $WEBSITE$を非表示", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "ページ読み込み時に自動入力する" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "エクストラワイド" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "入力されたパスワードが間違っています。" }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index dc104c4a7f3..d74b4f225fe 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "შეცდომა" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index db750969f43..c15ab367666 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 7e0f427ea69..20e1cec5280 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "ಶಾಶ್ವತವಾಗಿ ಅಳಿಸಲಾದ ಐಟಂ" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "ಐಟಂ ಅನ್ನು ಮರುಸ್ಥಾಪಿಸಿ" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 602c54c5f64..8cedaf14acc 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "패스키를 사용하여 로그인하기" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "통합인증(SSO) 사용하기" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "항목 보관" }, - "archiveItemConfirmDesc": { - "message": "보관된 항목은 일반 검색 결과와 자동 완성 제안에서 제외됩니다. 이 항목을 보관하시겠습니까?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "영구적으로 삭제된 항목" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "항목 복원" }, @@ -3364,6 +3370,12 @@ "error": { "message": "오류" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "다른 옵션 - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "$WEBSITE$ 일치 인식 숨기기", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "페이지 로드 시 자동 완성을 할까요?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "매우 넓게" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 4ba52318c0b..eac510ea668 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Prisijungti naudojant prieigos raktą" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Naudoti vieningo prisijungimo sistemą" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Ištrintas visam laikui" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Atkurti elementą" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Klaida" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index e75255ab829..6c5ee5adb98 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Pieteikties ar piekļuves atslēgu" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Izmantot vienoto pieteikšanos" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Arhivēt vienumu" }, - "archiveItemConfirmDesc": { - "message": "Arhivētie vienumi netiek iekļauti vispārējās meklēšanas iznākumos un automātiskās aizpildes ieteikumos. Vai tiešām ahrivēt šo vienumu?" + "archiveItemDialogContent": { + "message": "Pēc ievietošanas arhīvā šis vienums netiks iekļauts meklēšanas iznākumā un automātiskās aizpildes ieteikumos." }, "archived": { "message": "Arhivēts" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Vienums ir neatgriezeniski izdzēsts" }, + "archivedItemRestored": { + "message": "Arhīva vienums atjaunots" + }, "restoreItem": { "message": "Atjaunot vienumu" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Kļūda" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Atšifrēšanas kļūda" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Vairāk iespēju" + }, "moreOptionsTitle": { "message": "Vairāk iespēju - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Paslēpt atbilstības noteikšanu $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Rādīt atbilstības noteikšanu" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Slēpt atbilstības noteikšanu" }, "autoFillOnPageLoad": { "message": "Automātiski aizpildīt lapas ielādes brīdī?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Ļoti plats" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Ievadītā parole ir nepareiza." }, diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 7afe2b75287..35ff7b94d4c 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "ശാശ്വതമായി ഇല്ലാതാക്കിയ ഇനം" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "ഇനം വീണ്ടെടുക്കുക " }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 4d355428078..bae23dcd94d 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index db750969f43..c15ab367666 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 47f9ef4f7a9..993d7a1f0db 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Logg inn med passnøkkel" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Bruk singulær pålogging" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Slettet objektet permanent" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Gjenopprett objekt" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Feil" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Dekrypteringsfeil" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Flere innstillinger - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Vil du auto-utfylle ved sideinnlasting?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Ekstra bred" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Passordet du skrev inn er feil." }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index db750969f43..c15ab367666 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 0157fa800f9..504868fc5c8 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Inloggen met passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Single sign-on gebruiken" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Item archiveren" }, - "archiveItemConfirmDesc": { - "message": "Gearchiveerde items worden uitgesloten van algemene zoekresultaten en automatische invulsuggesties. Weet je zeker dat je dit item wilt archiveren?" + "archiveItemDialogContent": { + "message": "Eenmaal gearchiveerd wordt dit item uitgesloten van zoekresultaten en suggesties voor automatisch invullen." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Definitief verwijderd item" }, + "archivedItemRestored": { + "message": "Gearchiveerd item hersteld" + }, "restoreItem": { "message": "Item herstellen" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Fout" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Ontsleutelingsfout" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Meer opties" + }, "moreOptionsTitle": { "message": "Meer opties - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Overeenkomstdetectie verbergen $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Overeenkomstdetectie weergeven" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Overeenkomstdetectie verbergen" }, "autoFillOnPageLoad": { "message": "Automatisch invullen bij laden van pagina?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra breed" }, + "narrow": { + "message": "Smal" + }, "sshKeyWrongPassword": { "message": "Het wachtwoord dat je hebt ingevoerd is onjuist." }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index db750969f43..c15ab367666 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index db750969f43..c15ab367666 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 705fa9565f1..f8d8d6bfd69 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Logowanie kluczem dostępu" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Użyj logowania jednokrotnego" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archiwizuj element" }, - "archiveItemConfirmDesc": { - "message": "Zarchiwizowane elementy są wykluczone z wyników wyszukiwania i sugestii autouzupełniania. Czy na pewno chcesz archiwizować element?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Element został trwale usunięty" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Przywróć element" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Błąd" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Błąd odszyfrowywania" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Więcej opcji" + }, "moreOptionsTitle": { "message": "Więcej opcji - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Ukryj wykrywanie dopasowania $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Pokaż wykrywanie dopasowania" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Ukryj wykrywanie dopasowania" }, "autoFillOnPageLoad": { "message": "Włączyć autouzupełnianie po załadowaniu strony?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Bardzo szeroka" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Hasło jest nieprawidłowe." }, diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 5027fb35af2..a83d15be1b1 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Conectar-se com chave de acesso" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Usar autenticação única" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Arquivar item" }, - "archiveItemConfirmDesc": { - "message": "Itens arquivados são excluídos dos resultados gerais de busca e das sugestões de preenchimento automático. Tem certeza de que deseja arquivar este item?" + "archiveItemDialogContent": { + "message": "Ao arquivar, o item será excluído dos resultados de busca e sugestões de preenchimento automático." }, "archived": { "message": "Arquivados" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item apagado para sempre" }, + "archivedItemRestored": { + "message": "Item arquivado restaurado" + }, "restoreItem": { "message": "Restaurar item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Erro" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Erro de descriptografia" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Mais opções" + }, "moreOptionsTitle": { "message": "Mais opções - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Ocultar detecção de correspondência $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Mostrar detecção de correspondência" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Ocultar detecção de correspondência" }, "autoFillOnPageLoad": { "message": "Preencher ao carregar a página?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra larga" }, + "narrow": { + "message": "Estreita" + }, "sshKeyWrongPassword": { "message": "A senha que você digitou está incorreta." }, diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 7f97ab08623..2b40e2003a5 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Iniciar sessão com a chave de acesso" }, + "unlockWithPasskey": { + "message": "Desbloquear com chave de acesso" + }, "useSingleSignOn": { "message": "Utilizar início de sessão único" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Arquivar item" }, - "archiveItemConfirmDesc": { - "message": "Os itens arquivados são excluídos dos resultados gerais da pesquisa e das sugestões de preenchimento automático. Tem a certeza de que pretende arquivar este item?" + "archiveItemDialogContent": { + "message": "Depois de arquivado, este item será excluído dos resultados de pesquisa e das sugestões de preenchimento automático." }, "archived": { "message": "Arquivado" @@ -1071,7 +1074,7 @@ } }, "deleteItemConfirmation": { - "message": "Tem a certeza de que pretende eliminar este item?" + "message": "Pretende realmente mover este item para o lixo?" }, "deletedItem": { "message": "Item movido para o lixo" @@ -1552,7 +1555,7 @@ "message": "Para recuperar o acesso ao seu arquivo, reinicie a sua subscrição Premium. Se editar os detalhes de um item arquivado antes de reiniciar, ele será movido de volta para o seu cofre." }, "restartPremium": { - "message": "Reiniciar Premium" + "message": "Reiniciar o Premium" }, "ppremiumSignUpReports": { "message": "Higiene de palavras-passe, saúde da conta e relatórios de violação de dados para manter o seu cofre seguro." @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item eliminado permanentemente" }, + "archivedItemRestored": { + "message": "Item arquivado restaurado" + }, "restoreItem": { "message": "Restaurar item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Erro" }, + "prfUnlockFailed": { + "message": "Não foi possível desbloquear com a chave de acesso. Por favor, tente novamente ou utilize outro método de desbloqueio." + }, + "noPrfCredentialsAvailable": { + "message": "Não estão disponíveis chaves de acesso com PRF ativado para o desbloqueio. Por favor, inicie sessão primeiro com uma chave de acesso." + }, "decryptionError": { "message": "Erro de desencriptação" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Mais opções" + }, "moreOptionsTitle": { "message": "Mais opções - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Ocultar deteção de correspondência para $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Mostrar deteção de correspondência" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Ocultar deteção de correspondência" }, "autoFillOnPageLoad": { "message": "Preencher automaticamente ao carregar a página?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Muito ampla" }, + "narrow": { + "message": "Estreito" + }, "sshKeyWrongPassword": { "message": "A palavra-passe que introduziu está incorreta." }, diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 139b94d1a55..b071d8c765e 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Autentificare cu parolă" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Autentificare unică" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Articol șters permanent" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restabilire articol" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Eroare" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 0ab286c819d..c2b09803c06 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Войти с passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Использовать единый вход" }, @@ -582,14 +585,14 @@ "archiveItem": { "message": "Архивировать элемент" }, - "archiveItemConfirmDesc": { - "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" + "archiveItemDialogContent": { + "message": "После архивации этот элемент будет исключен из результатов поиска и предложений по автозаполнению." }, "archived": { - "message": "Archived" + "message": "Архивирован" }, "unarchiveAndSave": { - "message": "Unarchive and save" + "message": "Разархивировать и сохранить" }, "upgradeToUseArchive": { "message": "Для использования архива требуется премиум-статус." @@ -1546,13 +1549,13 @@ "message": "Проприетарные варианты двухэтапной аутентификации, такие как YubiKey или Duo." }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Ваша подписка Премиум закончилась" }, "archivePremiumRestart": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + "message": "Чтобы восстановить доступ к своему архиву, подключите подписку Премиум повторно. Если вы измените сведения об архивированном элементе перед переподключением, он будет перемещен обратно в ваше хранилище." }, "restartPremium": { - "message": "Restart Premium" + "message": "Переподключить Премиум" }, "ppremiumSignUpReports": { "message": "Гигиена паролей, здоровье аккаунта и отчеты об утечках данных для обеспечения безопасности вашего хранилища." @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Элемент удален навсегда" }, + "archivedItemRestored": { + "message": "Архивированный элемент восстановлен" + }, "restoreItem": { "message": "Восстановить элемент" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Ошибка" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Ошибка расшифровки" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Больше опций" + }, "moreOptionsTitle": { "message": "Больше опций - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4848,19 +4863,19 @@ "message": "Узнайте о рисках" }, "autoConfirmSetup": { - "message": "Automatically confirm new users" + "message": "Автоматически подтверждать новых пользователей" }, "autoConfirmSetupDesc": { - "message": "New users will be automatically confirmed while this device is unlocked." + "message": "Новые пользователи будут автоматически подтверждены при разблокировке устройства." }, "autoConfirmSetupHint": { - "message": "What are the potential security risks?" + "message": "Каковы потенциальные риски для безопасности?" }, "autoConfirmEnabled": { - "message": "Turned on automatic confirmation" + "message": "Включено автоматическое подтверждение" }, "availableNow": { - "message": "Available now" + "message": "Уже доступно" }, "accountSecurity": { "message": "Безопасность аккаунта" @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Скрыть обнаружение совпадений $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Показать обнаружение совпадений" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Скрыть обнаружение совпадений" }, "autoFillOnPageLoad": { "message": "Автозаполнение при загрузке страницы?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Очень широкое" }, + "narrow": { + "message": "Узкий" + }, "sshKeyWrongPassword": { "message": "Введенный пароль неверен." }, diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 82fed7f5fd4..c2451a18133 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "ස්ථිරව මකා දැමූ අයිතමය" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "අයිතමය යළි පිහිටුවන්න" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index f5619ef017d..5e1511eebac 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Prihlásiť sa s prístupovým kľúčom" }, + "unlockWithPasskey": { + "message": "Odomknúť pomocou prístupového kľúča" + }, "useSingleSignOn": { "message": "Použiť jednotné prihlásenie" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archivovať položku" }, - "archiveItemConfirmDesc": { - "message": "Archivované položky sú vylúčené zo všeobecného vyhľadávania a z návrhov automatického vypĺňania. Naozaj chcete archivovať túto položku?" + "archiveItemDialogContent": { + "message": "Po archivácii bude táto položka vylúčená z výsledkov vyhľadávania a návrhov automatického vypĺňania." }, "archived": { "message": "Archivované" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Položka bola natrvalo odstránená" }, + "archivedItemRestored": { + "message": "Archivovaná položka bola obnovená" + }, "restoreItem": { "message": "Obnoviť položku" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Chyba" }, + "prfUnlockFailed": { + "message": "Odomknutie pomocou prístupového kľúča zlyhalo. Skúste to znovu alebo použite inú metódu odomknutia." + }, + "noPrfCredentialsAvailable": { + "message": "Na odomkntuie nie sú k dispozícii žiadne PRF-enabled prístupové kľúče. Najskôr sa prihláste pomocou prístupového kľúča." + }, "decryptionError": { "message": "Chyba dešifrovania" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Ďalšie možnosti" + }, "moreOptionsTitle": { "message": "Ďalšie možnosti - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Skryť spôsob zisťovania zhody $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Zobraziť spôsob mapovania" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Skryť spôsob mapovania" }, "autoFillOnPageLoad": { "message": "Automaticky vyplniť pri načítaní stránky?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra široké" }, + "narrow": { + "message": "Úzke" + }, "sshKeyWrongPassword": { "message": "Zadané heslo je nesprávne." }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index a0ccbdeadbc..23d0312caae 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Element trajno izbrisan" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Obnovi element" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Napaka" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index f5e85127639..d3b5e961ef3 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Пријавите се са приступним кључем" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Употребити једнократну пријаву" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Архивирај ставку" }, - "archiveItemConfirmDesc": { - "message": "Архивиране ставке су искључене из општих резултата претраге и предлога за ауто попуњавање. Јесте ли сигурни да желите да архивирате ову ставку?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Трајно избрисана ставка" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Врати ставку" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Грешка" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Грешка при декрипцији" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "Више опција - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Сакриј откривање подударања $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Ауто-попуњавање при учитавању странице?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Врло широко" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "Лозинка коју сте унели није тачна." }, diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 2aef3f0d6d8..ca5984b672e 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Logga in med nyckel" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Använd Single Sign-On" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Arkivera objekt" }, - "archiveItemConfirmDesc": { - "message": "Arkiverade objekt är exkluderade från allmänna sökresultat och förslag för autofyll. Är du säker på att du vill arkivera detta objekt?" + "archiveItemDialogContent": { + "message": "När du har arkiverat kommer detta objekt att uteslutas från sökresultat och förslag till autofyll." }, "archived": { "message": "Arkiverade" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Raderade objekt permanent" }, + "archivedItemRestored": { + "message": "Arkiverat objekt återställt" + }, "restoreItem": { "message": "Återställ objekt" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Fel" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Dekrypteringsfel" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Fler alternativ" + }, "moreOptionsTitle": { "message": "Fler alternativ - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Detektering av dold matchning $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Visa matchningsdetektering" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Dölj matchdetektering" }, "autoFillOnPageLoad": { "message": "Autofyll vid sidladdning?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra bred" }, + "narrow": { + "message": "Smal" + }, "sshKeyWrongPassword": { "message": "Lösenordet du har angett är felaktigt." }, diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index c329028526a..44a284db9c6 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "பாஸ்கீயுடன் உள்நுழையவும்" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "ஒற்றை உள்நுழைவைப் பயன்படுத்தவும்" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "உருப்படியைக் காப்பகப்படுத்து" }, - "archiveItemConfirmDesc": { - "message": "காப்பகப்படுத்தப்பட்ட உருப்படிகள் பொதுவான தேடல் முடிவுகள் மற்றும் தானியங்குநிரப்பு பரிந்துரைகளிலிருந்து விலக்கப்பட்டுள்ளன. இந்த உருப்படியை காப்பகப்படுத்த விரும்புகிறீர்களா?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "பொருள் நிரந்தரமாக நீக்கப்பட்டது" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "பொருளை மீட்டமை" }, @@ -3364,6 +3370,12 @@ "error": { "message": "பிழை" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "குறியாக்கம் நீக்கப் பிழை" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "மேலும் விருப்பத்தேர்வுகள் - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "பொருத்தமான கண்டறிதலை மறை $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "பக்கத்தை ஏற்றும்போது தானாக நிரப்ப வேண்டுமா?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "அதிக அகலமானது" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "நீங்கள் உள்ளிடப்பட்ட கடவுச்சொல் தவறானது." }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index db750969f43..c15ab367666 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "More options - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Hide match detection $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "Autofill on page load?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 49ed1cebd89..d41ae49904d 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "เข้าสู่ระบบด้วยพาสคีย์" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "ใช้การลงชื่อเข้าใช้แบบ SSO" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "จัดเก็บรายการถาวร" }, - "archiveItemConfirmDesc": { - "message": "รายการที่จัดเก็บถาวรจะไม่ถูกรวมในผลการค้นหาทั่วไปและคำแนะนำการป้อนอัตโนมัติ ยืนยันที่จะจัดเก็บรายการนี้ถาวรหรือไม่" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Archived" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "ลบรายการถาวรแล้ว" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "กู้คืนรายการ" }, @@ -3364,6 +3370,12 @@ "error": { "message": "ข้อผิดพลาด" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "ข้อผิดพลาดในการถอดรหัส" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "More options" + }, "moreOptionsTitle": { "message": "ตัวเลือกเพิ่มเติม - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "ซ่อนการตรวจสอบการจับคู่ $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Show match detection" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Hide match detection" }, "autoFillOnPageLoad": { "message": "ป้อนอัตโนมัติเมื่อโหลดหน้าเว็บหรือไม่" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "กว้างพิเศษ" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "รหัสผ่านที่คุณป้อนไม่ถูกต้อง" }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 344b8e03835..83461d1a8a0 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Geçiş anahtarıyla giriş yap" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Çoklu oturum açma kullan" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Kaydı arşivle" }, - "archiveItemConfirmDesc": { - "message": "Arşivlenmiş kayıtlar genel arama sonuçları ve otomatik doldurma önerilerinden hariç tutulur. Bu kaydı arşivlemek istediğinizden emin misiniz?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "archived": { "message": "Arşivlendi" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Kayıt kalıcı olarak silindi" }, + "archivedItemRestored": { + "message": "Arşivlenmiş kayıt geri getirildi" + }, "restoreItem": { "message": "Kaydı geri yükle" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Hata" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Şifre çözme sorunu" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Diğer seçenekler" + }, "moreOptionsTitle": { "message": "Diğer seçenekler - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "$WEBSITE$ eşleşme tespitini gizle", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Eşleşme tespitini göster" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Eşleşme tespitini gizle" }, "autoFillOnPageLoad": { "message": "Sayfa yüklenince otomatik doldur" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Ekstra geniş" }, + "narrow": { + "message": "Dar" + }, "sshKeyWrongPassword": { "message": "Girdiğiniz parola yanlış." }, diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 01de2bbadf6..fdbd2508c44 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Увійти з ключем доступу" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Використати єдиний вхід" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "Архівувати запис" }, - "archiveItemConfirmDesc": { - "message": "Архівовані записи виключаються з результатів звичайного пошуку та пропозицій автозаповнення. Ви дійсно хочете архівувати цей запис?" + "archiveItemDialogContent": { + "message": "Після архівації цей запис буде виключено з результатів пошуку і пропозицій автозаповнення." }, "archived": { "message": "Архівовано" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Запис остаточно видалено" }, + "archivedItemRestored": { + "message": "Архівований запис відновлено" + }, "restoreItem": { "message": "Відновити запис" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Помилка" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Помилка розшифрування" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Більше опцій" + }, "moreOptionsTitle": { "message": "Інші можливості – $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Приховати виявлення збігів $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Показати виявлення збігів" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Приховати виявлення збігів" }, "autoFillOnPageLoad": { "message": "Автоматично заповнювати під час завантаження сторінки?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Дуже широке" }, + "narrow": { + "message": "Вузький" + }, "sshKeyWrongPassword": { "message": "Ви ввели неправильний пароль." }, diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 4ea7ebf885f..fdac572e550 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Đăng nhập bằng khóa truy cập" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Dùng đăng nhập một lần" }, @@ -437,7 +440,7 @@ "message": "Đồng bộ" }, "syncNow": { - "message": "Sync now" + "message": "Đồng bộ ngay" }, "lastSync": { "message": "Đồng bộ lần cuối:" @@ -574,7 +577,7 @@ "message": "Mục đã được chuyển vào kho lưu trữ" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "Mục đã được bỏ lưu trữ" }, "itemUnarchived": { "message": "Mục đã được bỏ lưu trữ" @@ -582,20 +585,20 @@ "archiveItem": { "message": "Lưu trữ mục" }, - "archiveItemConfirmDesc": { - "message": "Các mục đã lưu trữ sẽ bị loại khỏi kết quả tìm kiếm chung và gợi ý tự động điền. Bạn có chắc chắn muốn lưu trữ mục này không?" + "archiveItemDialogContent": { + "message": "Khi đã lưu trữ, mục này sẽ bị loại khỏi kết quả tìm kiếm và gợi ý tự động điền." }, "archived": { - "message": "Archived" + "message": "Đã lưu trữ" }, "unarchiveAndSave": { - "message": "Unarchive and save" + "message": "Bỏ lưu trữ và lưu" }, "upgradeToUseArchive": { "message": "Cần là thành viên cao cấp để sử dụng tính năng Lưu trữ." }, "itemRestored": { - "message": "Item has been restored" + "message": "Mục đã được khôi phục" }, "edit": { "message": "Sửa" @@ -1335,19 +1338,19 @@ "message": "Xuất từ" }, "exportVerb": { - "message": "Export", + "message": "Xuất", "description": "The verb form of the word Export" }, "exportNoun": { - "message": "Export", + "message": "Xuất", "description": "The noun form of the word Export" }, "importNoun": { - "message": "Import", + "message": "Nhập", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Nhập", "description": "The verb form of the word Import" }, "fileFormat": { @@ -1546,13 +1549,13 @@ "message": "Các tùy chọn xác minh hai bước như YubiKey và Duo." }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Gói đăng ký Cao cấp của bạn đã kết thúc" }, "archivePremiumRestart": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + "message": "Để lấy lại quyền truy cập vào lưu trữ của bạn, hãy khởi động lại gói đăng ký Cao cấp. Nếu bạn chỉnh sửa chi tiết cho một mục đã lưu trữ trước khi khởi động lại, mục đó sẽ được chuyển trở lại kho của bạn." }, "restartPremium": { - "message": "Restart Premium" + "message": "Khởi động lại gói Cao cấp" }, "ppremiumSignUpReports": { "message": "Thanh lọc mật khẩu, kiểm tra an toàn tài khoản và các báo cáo rò rỉ dữ liệu để bảo vệ kho dữ liệu của bạn." @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "Đã xóa vĩnh viễn mục" }, + "archivedItemRestored": { + "message": "Mục lưu trữ đã được khôi phục" + }, "restoreItem": { "message": "Khôi phục mục" }, @@ -3364,6 +3370,12 @@ "error": { "message": "Lỗi" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Lỗi giải mã" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "Thêm tuỳ chọn" + }, "moreOptionsTitle": { "message": "Thêm tùy chọn - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -4830,49 +4845,49 @@ "message": "Bảng điều khiển dành cho quản trị viên" }, "admin": { - "message": "Admin" + "message": "Quản trị" }, "automaticUserConfirmation": { - "message": "Automatic user confirmation" + "message": "Tự động xác nhận người dùng" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "message": "Tự động xác nhận người dùng đang chờ trong khi thiết bị này được mở khóa" }, "autoConfirmOnboardingCallout": { - "message": "Save time with automatic user confirmation" + "message": "Tiết kiệm thời gian với xác nhận người dùng tự động" }, "autoConfirmWarning": { - "message": "This could impact your organization’s data security. " + "message": "Điều này có thể ảnh hưởng đến bảo mật dữ liệu của tổ chức bạn. " }, "autoConfirmWarningLink": { - "message": "Learn about the risks" + "message": "Tìm hiểu về các rủi ro" }, "autoConfirmSetup": { - "message": "Automatically confirm new users" + "message": "Tự động xác nhận người dùng mới" }, "autoConfirmSetupDesc": { - "message": "New users will be automatically confirmed while this device is unlocked." + "message": "Người dùng mới sẽ được tự động xác nhận trong khi thiết bị này được mở khóa." }, "autoConfirmSetupHint": { - "message": "What are the potential security risks?" + "message": "Những rủi ro bảo mật tiềm ẩn là gì?" }, "autoConfirmEnabled": { - "message": "Turned on automatic confirmation" + "message": "Đã bật xác nhận tự động" }, "availableNow": { - "message": "Available now" + "message": "Khả dụng bây giờ" }, "accountSecurity": { "message": "Bảo mật tài khoản" }, "phishingBlocker": { - "message": "Phishing Blocker" + "message": "Trình chặn lừa đảo" }, "enablePhishingDetection": { - "message": "Phishing detection" + "message": "Phát hiện lừa đảo" }, "enablePhishingDetectionDesc": { - "message": "Display warning before accessing suspected phishing sites" + "message": "Hiển thị cảnh báo trước khi truy cập các trang web nghi ngờ lừa đảo" }, "notifications": { "message": "Thông báo" @@ -4990,7 +5005,7 @@ "message": "Tải xuống Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Tải xuống Bitwarden trên tất cả thiết bị" + "message": "Tải về Bitwarden trên mọi thiết bị" }, "getTheMobileApp": { "message": "Tải ứng dụng di động" @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "Ẩn phát hiện trùng khớp $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "Hiển thị phát hiện trùng khớp" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "Ẩn phát hiện trùng khớp" }, "autoFillOnPageLoad": { "message": "Tự động điền khi tải trang?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "Rộng hơn" }, + "narrow": { + "message": "Hẹp" + }, "sshKeyWrongPassword": { "message": "Mật khẩu bạn đã nhập không đúng." }, @@ -5719,10 +5734,10 @@ "message": "Thông tin đăng nhập này có rủi ro và thiếu một trang web. Hãy thêm trang web và đổi mật khẩu để tăng cường bảo mật." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Mật khẩu dễ bị tấn công." }, "changeNow": { - "message": "Change now" + "message": "Thay đổi ngay" }, "missingWebsite": { "message": "Thiếu trang web" @@ -5964,43 +5979,43 @@ "message": "Số thẻ" }, "removeMasterPasswordForOrgUserKeyConnector": { - "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." + "message": "Tổ chức của bạn không còn sử dụng mật khẩu chính để đăng nhập vào Bitwarden. Để tiếp tục, hãy xác minh tổ chức và tên miền." }, "continueWithLogIn": { - "message": "Continue with log in" + "message": "Tiếp tục đăng nhập" }, "doNotContinue": { - "message": "Do not continue" + "message": "Không tiếp tục" }, "domain": { - "message": "Domain" + "message": "Tên miền" }, "keyConnectorDomainTooltip": { - "message": "This domain will store your account encryption keys, so make sure you trust it. If you're not sure, check with your admin." + "message": "Tên miền này sẽ lưu trữ các khóa mã hóa tài khoản của bạn, vì vậy hãy đảm bảo bạn tin tưởng nó. Nếu bạn không chắc chắn, hãy kiểm tra với quản trị viên của bạn." }, "verifyYourOrganization": { - "message": "Verify your organization to log in" + "message": "Xác minh tổ chức của bạn để đăng nhập" }, "organizationVerified": { - "message": "Organization verified" + "message": "Tổ chức đã được xác minh" }, "domainVerified": { - "message": "Domain verified" + "message": "Tên miền đã được xác minh" }, "leaveOrganizationContent": { - "message": "If you don't verify your organization, your access to the organization will be revoked." + "message": "Nếu bạn không xác minh tổ chức của mình, quyền truy cập vào tổ chức sẽ bị thu hồi." }, "leaveNow": { - "message": "Leave now" + "message": "Rời khỏi ngay" }, "verifyYourDomainToLogin": { - "message": "Verify your domain to log in" + "message": "Xác minh tên miền của bạn để đăng nhập" }, "verifyYourDomainDescription": { - "message": "To continue with log in, verify this domain." + "message": "Để tiếp tục đăng nhập, hãy xác minh tên miền này." }, "confirmKeyConnectorOrganizationUserDescription": { - "message": "To continue with log in, verify the organization and domain." + "message": "Để tiếp tục đăng nhập, hãy xác minh tổ chức và tên miền." }, "sessionTimeoutSettingsAction": { "message": "Hành động sau khi đóng kho" @@ -6101,6 +6116,6 @@ "message": "Tại sao tôi thấy điều này?" }, "resizeSideNavigation": { - "message": "Resize side navigation" + "message": "Thay đổi kích thước thanh bên" } } diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 9530388a4e5..a4dee24b56a 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "使用通行密钥登录" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "使用单点登录" }, @@ -582,14 +585,14 @@ "archiveItem": { "message": "归档项目" }, - "archiveItemConfirmDesc": { - "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" + "archiveItemDialogContent": { + "message": "归档后,此项目将被排除在一般搜索结果和自动填充建议之外。" }, "archived": { - "message": "Archived" + "message": "已归档" }, "unarchiveAndSave": { - "message": "Unarchive and save" + "message": "取消归档并保存" }, "upgradeToUseArchive": { "message": "需要高级会员才能使用归档。" @@ -1546,13 +1549,13 @@ "message": "专有的两步登录选项,如 YubiKey 和 Duo。" }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "您的高级版订阅已结束" }, "archivePremiumRestart": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + "message": "要重新获取归档内容的访问权限,请重启您的高级版订阅。如果您在重启前编辑了某个已归档项目的详细信息,它将被移回您的密码库中。" }, "restartPremium": { - "message": "Restart Premium" + "message": "重启高级版" }, "ppremiumSignUpReports": { "message": "密码健康、账户体检以及数据泄露报告,保障您的密码库安全。" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "项目已永久删除" }, + "archivedItemRestored": { + "message": "归档项目已恢复" + }, "restoreItem": { "message": "恢复项目" }, @@ -3152,10 +3158,10 @@ "message": "更新主密码" }, "updateMasterPasswordWarning": { - "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "updateWeakMasterPasswordWarning": { - "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "tdeDisabledMasterPasswordRequired": { "message": "您的组织禁用了信任设备加密。要访问您的密码库,请设置一个主密码。" @@ -3364,6 +3370,12 @@ "error": { "message": "错误" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "解密错误" }, @@ -3378,7 +3390,7 @@ "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { - "message": "以避免额外的数据丢失。", + "message": "以避免进一步的数据丢失。", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "generateUsername": { @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "更多选项" + }, "moreOptionsTitle": { "message": "更多选项 - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "隐藏匹配检测 $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "显示匹配检测" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "隐藏匹配检测" }, "autoFillOnPageLoad": { "message": "页面加载时自动填充吗?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "超宽" }, + "narrow": { + "message": "窄" + }, "sshKeyWrongPassword": { "message": "您输入的密码不正确。" }, diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index 76407d95621..540a4b053ff 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "使用密碼金鑰登入" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "使用單一登入" }, @@ -582,8 +585,8 @@ "archiveItem": { "message": "封存項目" }, - "archiveItemConfirmDesc": { - "message": "封存的項目將不會出現在一般搜尋結果或自動填入建議中。確定要封存此項目嗎?" + "archiveItemDialogContent": { + "message": "封存後,此項目將不會顯示在搜尋結果與自動填入建議中。" }, "archived": { "message": "已封存" @@ -2473,6 +2476,9 @@ "permanentlyDeletedItem": { "message": "項目已永久刪除" }, + "archivedItemRestored": { + "message": "已還原封存項目" + }, "restoreItem": { "message": "還原項目" }, @@ -3364,6 +3370,12 @@ "error": { "message": "錯誤" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "解密發生錯誤" }, @@ -4739,6 +4751,9 @@ } } }, + "moreOptionsLabelNoPlaceholder": { + "message": "更多選項" + }, "moreOptionsTitle": { "message": "更多選項 - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", @@ -5125,14 +5140,11 @@ } } }, - "hideMatchDetection": { - "message": "隱藏偵測到的吻合 $WEBSITE$", - "placeholders": { - "website": { - "content": "$1", - "example": "https://example.com" - } - } + "showMatchDetectionNoPlaceholder": { + "message": "顯示比對偵測" + }, + "hideMatchDetectionNoPlaceholder": { + "message": "隱藏比對偵測" }, "autoFillOnPageLoad": { "message": "在頁面載入時自動填寫?" @@ -5670,6 +5682,9 @@ "extraWide": { "message": "超寬" }, + "narrow": { + "message": "縮小" + }, "sshKeyWrongPassword": { "message": "您輸入的密碼錯誤。" }, diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 25fcb9038d8..117c7c5e2a4 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -96,7 +96,9 @@ export class CollectAutofillContentService implements CollectAutofillContentServ */ async getPageDetails(): Promise { // Set up listeners on top-layer candidates that predate Mutation Observer setup - this.setupInitialTopLayerListeners(); + if (this.autofillOverlayContentService) { + this.setupInitialTopLayerListeners(); + } if (!this.mutationObserver) { this.setupMutationObserver(); @@ -1072,19 +1074,21 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } private setupTopLayerCandidateListener = (element: Element) => { - const ownedTags = this.autofillOverlayContentService.getOwnedInlineMenuTagNames() || []; - this.ownedExperienceTagNames = ownedTags; + if (this.autofillOverlayContentService) { + const ownedTags = this.autofillOverlayContentService.getOwnedInlineMenuTagNames() || []; + this.ownedExperienceTagNames = ownedTags; - if (!ownedTags.includes(element.tagName)) { - element.addEventListener("toggle", (event: ToggleEvent) => { - if (event.newState === "open") { - // Add a slight delay (but faster than a user's reaction), to ensure the layer - // positioning happens after any triggered toggle has completed. - setTimeout(this.autofillOverlayContentService.refreshMenuLayerPosition, 100); - } - }); + if (!ownedTags.includes(element.tagName)) { + element.addEventListener("toggle", (event: ToggleEvent) => { + if (event.newState === "open") { + // Add a slight delay (but faster than a user's reaction), to ensure the layer + // positioning happens after any triggered toggle has completed. + setTimeout(this.autofillOverlayContentService.refreshMenuLayerPosition, 100); + } + }); - this.autofillOverlayContentService.refreshMenuLayerPosition(); + this.autofillOverlayContentService.refreshMenuLayerPosition(); + } } }; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 3731e8a5423..660fcb97bcf 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1571,7 +1571,6 @@ export default class MainBackground { await this.sdkLoadService.loadAndInit(); // Only the "true" background should run migrations await this.migrationRunner.run(); - this.encryptService.init(this.configService); // This is here instead of in the InitService b/c we don't plan for // side effects to run in the Browser InitService. diff --git a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts index 4cd155c8ae3..6595104207a 100644 --- a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts +++ b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts @@ -1,10 +1,14 @@ export type PhishingResource = { name?: string; remoteUrl: string; + /** Fallback URL to use if remoteUrl fails (e.g., due to SSL interception/cert issues) */ + fallbackUrl: string; checksumUrl: string; todayUrl: string; /** Matcher used to decide whether a given URL matches an entry from this resource */ match: (url: URL, entry: string) => boolean; + /** Whether to use the custom matcher. If false, only exact hasUrl lookups are used. Default: true */ + useCustomMatcher?: boolean; }; export const PhishingResourceType = Object.freeze({ @@ -19,6 +23,8 @@ export const PHISHING_RESOURCES: Record { if (!entry) { return false; @@ -71,10 +81,10 @@ export const PHISHING_RESOURCES: Record - new Promise((resolve) => jest.requireActual("timers").setImmediate(resolve)); - -// [FIXME] Move mocking and compression helpers to a shared test utils library -// to separate from phishing data service tests. -export const setupPhishingMocks = (mockedResult: string | ArrayBuffer = "mocked-data") => { - // Store original globals - const originals = { - Response: global.Response, - CompressionStream: global.CompressionStream, - DecompressionStream: global.DecompressionStream, - Blob: global.Blob, - atob: global.atob, - btoa: global.btoa, - }; - - // Mock missing or browser-only globals - global.atob = (str) => Buffer.from(str, "base64").toString("binary"); - global.btoa = (str) => Buffer.from(str, "binary").toString("base64"); - - (global as any).CompressionStream = class {}; - (global as any).DecompressionStream = class {}; - - global.Blob = class { - constructor(public parts: any[]) {} - stream() { - return { pipeThrough: () => ({}) }; - } - } as any; - - global.Response = class { - body = { pipeThrough: () => ({}) }; - // Return string for decompression - text() { - return Promise.resolve(typeof mockedResult === "string" ? mockedResult : ""); - } - // Return ArrayBuffer for compression - arrayBuffer() { - if (typeof mockedResult === "string") { - const bytes = new TextEncoder().encode(mockedResult); - return Promise.resolve(bytes.buffer); - } - - return Promise.resolve(mockedResult); - } - } as any; - - // Cleanup function - return () => { - Object.assign(global, originals); - }; -}; +import { PHISHING_DOMAINS_META_KEY, PhishingDataService } from "./phishing-data.service"; +import type { PhishingIndexedDbService } from "./phishing-indexeddb.service"; describe("PhishingDataService", () => { let service: PhishingDataService; @@ -76,33 +19,31 @@ describe("PhishingDataService", () => { let taskSchedulerService: TaskSchedulerService; let logService: MockProxy; let platformUtilsService: MockProxy; + let mockIndexedDbService: MockProxy; const fakeGlobalStateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider(); - - const setMockMeta = (state: PhishingDataMeta) => { - fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_META_KEY).stateSubject.next(state); - return state; - }; - const setMockBlob = (state: PhishingDataBlob) => { - fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_BLOB_KEY).stateSubject.next(state); - return state; - }; - let fetchChecksumSpy: jest.SpyInstance; - let fetchAndCompressSpy: jest.SpyInstance; - - const mockMeta: PhishingDataMeta = { - checksum: "abc", - timestamp: Date.now(), - applicationVersion: "1.0.0", - }; - const mockBlob = "http://phish.com\nhttps://badguy.net"; - const mockCompressedBlob = - "H4sIAAAAAAAA/8vMTSzJzM9TSE7MLchJLElVyE9TyC9KSS1S0FFIz8hLz0ksSQUAtK7XMSYAAAA="; beforeEach(async () => { - jest.useFakeTimers(); + jest.clearAllMocks(); + + // Mock Request global if not available + if (typeof Request === "undefined") { + (global as any).Request = class { + constructor(public url: string) {} + }; + } + apiService = mock(); logService = mock(); + mockIndexedDbService = mock(); + + // Set default mock behaviors + mockIndexedDbService.hasUrl.mockResolvedValue(false); + mockIndexedDbService.loadAllUrls.mockResolvedValue([]); + mockIndexedDbService.findMatchingUrl.mockResolvedValue(false); + mockIndexedDbService.saveUrls.mockResolvedValue(undefined); + mockIndexedDbService.addUrls.mockResolvedValue(undefined); + mockIndexedDbService.saveUrlsFromStream.mockResolvedValue(undefined); platformUtilsService = mock(); platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); @@ -116,217 +57,308 @@ describe("PhishingDataService", () => { logService, platformUtilsService, ); - fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum"); - fetchAndCompressSpy = jest.spyOn(service as any, "fetchAndCompress"); + // Replace the IndexedDB service with our mock + service["indexedDbService"] = mockIndexedDbService; + + fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum"); fetchChecksumSpy.mockResolvedValue("new-checksum"); - fetchAndCompressSpy.mockResolvedValue("compressed-blob"); }); describe("initialization", () => { - beforeEach(() => { - jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob); - jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob); + it("should initialize with IndexedDB service", () => { + expect(service["indexedDbService"]).toBeDefined(); }); - it("should perform background update", async () => { - platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.x"); - jest - .spyOn(service as any, "getNextWebAddresses") - .mockResolvedValue({ meta: mockMeta, blob: mockBlob }); - - setMockBlob(mockBlob); - setMockMeta(mockMeta); - - const sub = service.update$.subscribe(); - await flushPromises(); - - const url = new URL("http://phish.com"); - const QAurl = new URL("http://phishing.testcategory.com"); + it("should detect QA test addresses - http protocol", async () => { + const url = new URL("http://phishing.testcategory.com"); expect(await service.isPhishingWebAddress(url)).toBe(true); - expect(await service.isPhishingWebAddress(QAurl)).toBe(true); + // IndexedDB should not be called for test addresses + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); + }); - sub.unsubscribe(); + it("should detect QA test addresses - https protocol", async () => { + const url = new URL("https://phishing.testcategory.com"); + expect(await service.isPhishingWebAddress(url)).toBe(true); + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); + }); + + it("should detect QA test addresses - specific subpath /block", async () => { + const url = new URL("https://phishing.testcategory.com/block"); + expect(await service.isPhishingWebAddress(url)).toBe(true); + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); + }); + + it("should NOT detect QA test addresses - different subpath", async () => { + mockIndexedDbService.hasUrl.mockResolvedValue(false); + mockIndexedDbService.findMatchingUrl.mockResolvedValue(false); + + const url = new URL("https://phishing.testcategory.com/other"); + const result = await service.isPhishingWebAddress(url); + + // This should NOT be detected as a test address since only /block subpath is hardcoded + expect(result).toBe(false); + }); + + it("should detect QA test addresses - root path with trailing slash", async () => { + const url = new URL("https://phishing.testcategory.com/"); + const result = await service.isPhishingWebAddress(url); + + // This SHOULD be detected since URLs are normalized (trailing slash added to root URLs) + expect(result).toBe(true); + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); }); }); describe("isPhishingWebAddress", () => { - beforeEach(() => { - jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob); - jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob); - }); + it("should detect a phishing web address using quick hasUrl lookup", async () => { + // Mock hasUrl to return true for direct hostname match + mockIndexedDbService.hasUrl.mockResolvedValue(true); - it("should detect a phishing web address", async () => { - service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); - - const url = new URL("http://phish.com"); + const url = new URL("http://phish.com/testing-param"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(true); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/testing-param"); + // Should not fall back to custom matcher when hasUrl returns true + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); + }); + + it("should return false when hasUrl returns false (custom matcher disabled)", async () => { + // Mock hasUrl to return false (no direct href match) + mockIndexedDbService.hasUrl.mockResolvedValue(false); + + const url = new URL("http://phish.com/path"); + const result = await service.isPhishingWebAddress(url); + + // Custom matcher is currently disabled (useCustomMatcher: false), so result is false + expect(result).toBe(false); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/path"); + // Custom matcher should NOT be called since it's disabled + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); it("should not detect a safe web address", async () => { - service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); + // Mock hasUrl to return false + mockIndexedDbService.hasUrl.mockResolvedValue(false); + const url = new URL("http://safe.com"); const result = await service.isPhishingWebAddress(url); + expect(result).toBe(false); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://safe.com/"); + // Custom matcher is disabled, so findMatchingUrl should NOT be called + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); - it("should match against root web address", async () => { - service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); - const url = new URL("http://phish.com/about"); + it("should not match against root web address with subpaths (custom matcher disabled)", async () => { + // Mock hasUrl to return false (no direct href match) + mockIndexedDbService.hasUrl.mockResolvedValue(false); + + const url = new URL("http://phish.com/login/page"); const result = await service.isPhishingWebAddress(url); - expect(result).toBe(true); + + expect(result).toBe(false); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page"); + // Custom matcher is disabled, so findMatchingUrl should NOT be called + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); - it("should not error on empty state", async () => { - service["_webAddressesSet"] = null; + it("should not match against root web address with different subpaths (custom matcher disabled)", async () => { + // Mock hasUrl to return false (no direct hostname match) + mockIndexedDbService.hasUrl.mockResolvedValue(false); + + const url = new URL("http://phish.com/login/page2"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(false); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page2"); + // Custom matcher is disabled, so findMatchingUrl should NOT be called + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); + }); + + it("should handle IndexedDB errors gracefully", async () => { + // Mock hasUrl to throw error + mockIndexedDbService.hasUrl.mockRejectedValue(new Error("hasUrl error")); + const url = new URL("http://phish.com/about"); const result = await service.isPhishingWebAddress(url); + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingDataService] IndexedDB lookup via hasUrl failed", + expect.any(Error), + ); + // Custom matcher is disabled, so no custom matcher error is expected + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); }); - describe("getNextWebAddresses", () => { - beforeEach(() => { - jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob); - jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob); + describe("data updates", () => { + it("should update full dataset via stream", async () => { + // Mock full dataset update + const mockResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); + + await firstValueFrom(service["_updateFullDataSet"]()); + + expect(mockIndexedDbService.saveUrlsFromStream).toHaveBeenCalled(); }); - it("refetches all web addresses if applicationVersion has changed", async () => { - const prev: PhishingDataMeta = { - timestamp: Date.now() - 60000, - checksum: "old", - applicationVersion: "1.0.0", - }; - fetchChecksumSpy.mockResolvedValue("new"); + it("should update daily dataset via addUrls", async () => { + // Mock daily update + const mockResponse = { + ok: true, + text: jest.fn().mockResolvedValue("newphish.com\nanotherbad.net"), + } as unknown as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); + + await firstValueFrom(service["_updateDailyDataSet"]()); + + expect(mockIndexedDbService.addUrls).toHaveBeenCalledWith(["newphish.com", "anotherbad.net"]); + }); + + it("should get updated meta information", async () => { + fetchChecksumSpy.mockResolvedValue("new-checksum"); platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0"); - const result = await service.getNextWebAddresses(prev); + const meta = await firstValueFrom(service["_getUpdatedMeta"]()); - expect(result!.blob).toBe("compressed-blob"); - expect(result!.meta!.checksum).toBe("new"); - expect(result!.meta!.applicationVersion).toBe("2.0.0"); - }); - - it("returns null when checksum matches and cache not expired", async () => { - const prev: PhishingDataMeta = { - timestamp: Date.now(), - checksum: "abc", - applicationVersion: "1.0.0", - }; - fetchChecksumSpy.mockResolvedValue("abc"); - const result = await service.getNextWebAddresses(prev); - expect(result).toBeNull(); - }); - - it("patches daily domains when cache is expired and checksum unchanged", async () => { - const prev: PhishingDataMeta = { - timestamp: 0, - checksum: "old", - applicationVersion: "1.0.0", - }; - const dailyLines = ["b.com", "c.com"]; - fetchChecksumSpy.mockResolvedValue("old"); - jest.spyOn(service as any, "fetchText").mockResolvedValue(dailyLines); - - setMockBlob(mockBlob); - - const expectedBlob = - "H4sIAAAAAAAA/8vMTSzJzM9TSE7MLchJLElVyE9TyC9KSS1S0FFIz8hLz0ksSQUAtK7XMSYAAAA="; - const result = await service.getNextWebAddresses(prev); - - expect(result!.blob).toBe(expectedBlob); - expect(result!.meta!.checksum).toBe("old"); - }); - - it("fetches all domains when checksum has changed", async () => { - const prev: PhishingDataMeta = { - timestamp: 0, - checksum: "old", - applicationVersion: "1.0.0", - }; - fetchChecksumSpy.mockResolvedValue("new"); - fetchAndCompressSpy.mockResolvedValue("new-blob"); - const result = await service.getNextWebAddresses(prev); - expect(result!.blob).toBe("new-blob"); - expect(result!.meta!.checksum).toBe("new"); + expect(meta).toBeDefined(); + expect(meta.checksum).toBe("new-checksum"); + expect(meta.applicationVersion).toBe("2.0.0"); + expect(meta.timestamp).toBeDefined(); }); }); - describe("compression helpers", () => { - let restore: () => void; + describe("phishing meta data updates", () => { + it("should not update metadata when no data updates occur", async () => { + // Set up existing metadata + const existingMeta = { + checksum: "existing-checksum", + timestamp: Date.now() - 1000, // 1 second ago (not expired) + applicationVersion: "1.0.0", + }; + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); - beforeEach(async () => { - restore = setupPhishingMocks("abc"); + // Mock conditions where no update is needed + fetchChecksumSpy.mockResolvedValue("existing-checksum"); // Same checksum + platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); // Same version + const mockResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); + + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); + + // Verify metadata was NOT updated (same reference returned) + expect(result).toEqual(existingMeta); + expect(result?.timestamp).toBe(existingMeta.timestamp); + + // Verify no data updates were performed + expect(mockIndexedDbService.saveUrlsFromStream).not.toHaveBeenCalled(); + expect(mockIndexedDbService.addUrls).not.toHaveBeenCalled(); }); - afterEach(() => { - if (restore) { - restore(); - } - delete (Uint8Array as any).fromBase64; - jest.restoreAllMocks(); + it("should update metadata when full dataset update occurs due to checksum change", async () => { + // Set up existing metadata + const existingMeta = { + checksum: "old-checksum", + timestamp: Date.now() - 1000, + applicationVersion: "1.0.0", + }; + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); + + // Mock conditions for full update + fetchChecksumSpy.mockResolvedValue("new-checksum"); // Different checksum + platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); + const mockResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); + + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); + + // Verify metadata WAS updated with new values + expect(result?.checksum).toBe("new-checksum"); + expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp); + + // Verify full update was performed + expect(mockIndexedDbService.saveUrlsFromStream).toHaveBeenCalled(); + expect(mockIndexedDbService.addUrls).not.toHaveBeenCalled(); // Daily should not run }); - describe("_compressString", () => { - it("compresses a string to base64", async () => { - const out = await service["_compressString"]("abc"); - expect(out).toBe("YWJj"); // base64 for 'abc' - }); + it("should update metadata when full dataset update occurs due to version change", async () => { + // Set up existing metadata + const existingMeta = { + checksum: "same-checksum", + timestamp: Date.now() - 1000, + applicationVersion: "1.0.0", + }; + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); - it("compresses using fallback on older browsers", async () => { - const input = "abc"; - const expected = btoa(encodeURIComponent(input)); - const out = await service["_compressString"](input); - expect(out).toBe(expected); - }); + // Mock conditions for full update + fetchChecksumSpy.mockResolvedValue("same-checksum"); + platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0"); // Different version + const mockResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); - it("compresses using btoa on error", async () => { - const input = "abc"; - const expected = btoa(encodeURIComponent(input)); - const out = await service["_compressString"](input); - expect(out).toBe(expected); - }); + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); + + // Verify metadata WAS updated + expect(result?.applicationVersion).toBe("2.0.0"); + expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp); + + // Verify full update was performed + expect(mockIndexedDbService.saveUrlsFromStream).toHaveBeenCalled(); + expect(mockIndexedDbService.addUrls).not.toHaveBeenCalled(); }); - describe("_decompressString", () => { - it("decompresses a string from base64", async () => { - const base64 = btoa("ignored"); - const out = await service["_decompressString"](base64); - expect(out).toBe("abc"); - }); - it("decompresses using fallback on older browsers", async () => { - // Provide a fromBase64 implementation - (Uint8Array as any).fromBase64 = (b64: string) => new Uint8Array([100, 101, 102]); + it("should update metadata when daily update occurs due to cache expiration", async () => { + // Set up existing metadata (expired cache) + const existingMeta = { + checksum: "same-checksum", + timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago (expired) + applicationVersion: "1.0.0", + }; + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); - const out = await service["_decompressString"]("ignored"); - expect(out).toBe("abc"); - }); + // Mock conditions for daily update only + fetchChecksumSpy.mockResolvedValue("same-checksum"); // Same checksum (no full update) + platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); // Same version + const mockFullResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + const mockDailyResponse = { + ok: true, + text: jest.fn().mockResolvedValue("newdomain.com"), + } as unknown as Response; + apiService.nativeFetch + .mockResolvedValueOnce(mockFullResponse) + .mockResolvedValueOnce(mockDailyResponse); - it("decompresses using atob on error", async () => { - const base64 = btoa(encodeURIComponent("abc")); - const out = await service["_decompressString"](base64); - expect(out).toBe("abc"); - }); - }); - }); + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); - describe("_loadBlobToMemory", () => { - it("loads blob into memory set", async () => { - const prevBlob = "ignored-base64"; - fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_BLOB_KEY).stateSubject.next(prevBlob); + // Verify metadata WAS updated + expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp); + expect(result?.checksum).toBe("same-checksum"); - jest.spyOn(service as any, "_decompressString").mockResolvedValue("phish.com\nbadguy.net"); - - // Trigger the load pipeline and allow async RxJS processing to complete - service["_loadBlobToMemory"](); - await flushPromises(); - - const set = service["_webAddressesSet"] as Set; - expect(set).toBeDefined(); - expect(set.has("phish.com")).toBe(true); - expect(set.has("badguy.net")).toBe(true); + // Verify only daily update was performed + expect(mockIndexedDbService.saveUrlsFromStream).not.toHaveBeenCalled(); + expect(mockIndexedDbService.addUrls).toHaveBeenCalledWith(["newdomain.com"]); }); }); }); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts index 7d5f04cc276..c34a94ecced 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -1,17 +1,25 @@ import { catchError, + concatMap, + defer, EMPTY, + exhaustMap, first, - firstValueFrom, + forkJoin, from, + iif, + map, + Observable, of, + retry, share, takeUntil, startWith, Subject, switchMap, tap, - map, + throwError, + timer, } from "rxjs"; import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags"; @@ -23,6 +31,8 @@ import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bi import { getPhishingResources, PhishingResourceType } from "../phishing-resources"; +import { PhishingIndexedDbService } from "./phishing-indexeddb.service"; + /** * Metadata about the phishing data set */ @@ -73,19 +83,16 @@ export class PhishingDataService { // We are adding the destroy to guard against accidental leaks. private _destroy$ = new Subject(); - private _testWebAddresses = this.getTestWebAddresses().concat("phishing.testcategory.com"); // Included for QA to test in prod + private _testWebAddresses = this.getTestWebAddresses(); private _phishingMetaState = this.globalStateProvider.get(PHISHING_DOMAINS_META_KEY); - private _phishingBlobState = this.globalStateProvider.get(PHISHING_DOMAINS_BLOB_KEY); - // In-memory set loaded from blob for fast lookups without reading large storage repeatedly - private _webAddressesSet: Set | null = null; - // Loading variables for web addresses set - // Triggers a load for _webAddressesSet - private _loadTrigger$ = new Subject(); + private indexedDbService: PhishingIndexedDbService; // How often are new web addresses added to the remote? readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours + private _backgroundUpdateTrigger$ = new Subject(); + private _triggerUpdate$ = new Subject(); update$ = this._triggerUpdate$.pipe( startWith(undefined), // Always emit once @@ -93,12 +100,8 @@ export class PhishingDataService { this._phishingMetaState.state$.pipe( first(), // Only take the first value to avoid an infinite loop when updating the cache below tap((metaState) => { - // Initial loading of web addresses set if not already loaded - if (!this._webAddressesSet) { - this._loadBlobToMemory(); - } - // Perform any updates in the background if needed - void this._backgroundUpdate(metaState); + // Perform any updates in the background + this._backgroundUpdateTrigger$.next(metaState); }), catchError((err: unknown) => { this.logService.error("[PhishingDataService] Background update failed to start.", err); @@ -106,7 +109,6 @@ export class PhishingDataService { }), ), ), - // Stop emitting when dispose() is called takeUntil(this._destroy$), share(), ); @@ -120,6 +122,7 @@ export class PhishingDataService { private resourceType: PhishingResourceType = PhishingResourceType.Links, ) { this.logService.debug("[PhishingDataService] Initializing service..."); + this.indexedDbService = new PhishingIndexedDbService(this.logService); this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => { this._triggerUpdate$.next(); }); @@ -127,18 +130,20 @@ export class PhishingDataService { ScheduledTaskNames.phishingDomainUpdate, this.UPDATE_INTERVAL_DURATION, ); - this._setupLoadPipeline(); + this._backgroundUpdateTrigger$ + .pipe( + exhaustMap((currentMeta) => { + return this._backgroundUpdate(currentMeta); + }), + takeUntil(this._destroy$), + ) + .subscribe(); } dispose(): void { // Signal all pipelines to stop and unsubscribe stored subscriptions this._destroy$.next(); this._destroy$.complete(); - - // Clear web addresses set from memory - if (this._webAddressesSet !== null) { - this._webAddressesSet = null; - } } /** @@ -148,105 +153,120 @@ export class PhishingDataService { * @returns True if the URL is a known phishing web address, false otherwise */ async isPhishingWebAddress(url: URL): Promise { - if (!this._webAddressesSet) { - this.logService.debug("[PhishingDataService] Set not loaded; skipping check"); + this.logService.debug("[PhishingDataService] isPhishingWebAddress called for: " + url.href); + + // Skip non-http(s) protocols - phishing database only contains web URLs + // This prevents expensive fallback checks for chrome://, about:, file://, etc. + if (url.protocol !== "http:" && url.protocol !== "https:") { + this.logService.debug("[PhishingDataService] Skipping non-http(s) protocol: " + url.protocol); return false; } - const set = this._webAddressesSet!; + // Quick check for QA/dev test addresses + if (this._testWebAddresses.includes(url.href)) { + this.logService.info("[PhishingDataService] Found test web address: " + url.href); + return true; + } + const resource = getPhishingResources(this.resourceType); - // Custom matcher per resource - if (resource && resource?.match) { - for (const entry of set) { - if (resource.match(url, entry)) { - return true; - } + try { + // Quick lookup: check direct presence of href in IndexedDB + // Also check without trailing slash since browsers add it but DB entries may not have it + const urlHref = url.href; + const urlWithoutTrailingSlash = urlHref.endsWith("/") ? urlHref.slice(0, -1) : null; + + this.logService.debug("[PhishingDataService] Checking hasUrl on this string: " + urlHref); + let hasUrl = await this.indexedDbService.hasUrl(urlHref); + + // If not found and URL has trailing slash, try without it + if (!hasUrl && urlWithoutTrailingSlash) { + this.logService.debug( + "[PhishingDataService] Checking hasUrl without trailing slash: " + + urlWithoutTrailingSlash, + ); + hasUrl = await this.indexedDbService.hasUrl(urlWithoutTrailingSlash); } - return false; + + if (hasUrl) { + this.logService.info( + "[PhishingDataService] Found phishing web address through direct lookup: " + urlHref, + ); + return true; + } + } catch (err) { + this.logService.error("[PhishingDataService] IndexedDB lookup via hasUrl failed", err); } - // Default set-based lookup - return set.has(url.hostname); - } - - async getNextWebAddresses( - previous: PhishingDataMeta | null, - ): Promise | null> { - const prevMeta = previous ?? { timestamp: 0, checksum: "", applicationVersion: "" }; - const now = Date.now(); - - // Updates to check - const applicationVersion = await this.platformUtilsService.getApplicationVersion(); - const remoteChecksum = await this.fetchPhishingChecksum(this.resourceType); - - // Logic checks - const appVersionChanged = applicationVersion !== prevMeta.applicationVersion; - const masterChecksumChanged = remoteChecksum !== prevMeta.checksum; - - // Check for full updated - if (masterChecksumChanged || appVersionChanged) { - this.logService.info("[PhishingDataService] Checksum or version changed; Fetching ALL."); - const remoteUrl = getPhishingResources(this.resourceType)!.remoteUrl; - const blob = await this.fetchAndCompress(remoteUrl); - return { - blob, - meta: { checksum: remoteChecksum, timestamp: now, applicationVersion }, - }; - } - - // Check for daily file - const isCacheExpired = now - prevMeta.timestamp > this.UPDATE_INTERVAL_DURATION; - - if (isCacheExpired) { - this.logService.info("[PhishingDataService] Daily cache expired; Fetching TODAY's"); - const url = getPhishingResources(this.resourceType)!.todayUrl; - const newLines = await this.fetchText(url); - const prevBlob = (await firstValueFrom(this._phishingBlobState.state$)) ?? ""; - const oldText = prevBlob ? await this._decompressString(prevBlob) : ""; - - // Join the new lines to the existing list - const combined = (oldText ? oldText + "\n" : "") + newLines.join("\n"); - - return { - blob: await this._compressString(combined), - meta: { - checksum: remoteChecksum, - timestamp: now, // Reset the timestamp - applicationVersion, - }, - }; - } - - return null; + // If a custom matcher is provided and enabled, use cursor-based search. + // This avoids loading all URLs into memory and allows early exit on first match. + // Can be disabled via useCustomMatcher: false for performance reasons. + if (resource && resource.match && resource.useCustomMatcher !== false) { + try { + this.logService.debug( + "[PhishingDataService] Starting cursor-based search for: " + url.href, + ); + const startTime = performance.now(); + + const found = await this.indexedDbService.findMatchingUrl((entry) => + resource.match(url, entry), + ); + + const endTime = performance.now(); + const duration = (endTime - startTime).toFixed(2); + this.logService.debug( + `[PhishingDataService] Cursor-based search completed in ${duration}ms for: ${url.href} (found: ${found})`, + ); + + if (found) { + this.logService.info( + "[PhishingDataService] Found phishing web address through custom matcher: " + url.href, + ); + } else { + this.logService.debug( + "[PhishingDataService] No match found, returning false for: " + url.href, + ); + } + return found; + } catch (err) { + this.logService.error("[PhishingDataService] Error running custom matcher", err); + this.logService.debug( + "[PhishingDataService] Returning false due to error for: " + url.href, + ); + return false; + } + } + this.logService.debug( + "[PhishingDataService] No custom matcher, returning false for: " + url.href, + ); + return false; } + // [FIXME] Pull fetches into api service private async fetchPhishingChecksum(type: PhishingResourceType = PhishingResourceType.Domains) { const checksumUrl = getPhishingResources(type)!.checksumUrl; - const response = await this.apiService.nativeFetch(new Request(checksumUrl)); - if (!response.ok) { - throw new Error(`[PhishingDataService] Failed to fetch checksum: ${response.status}`); - } - return response.text(); - } - private async fetchAndCompress(url: string): Promise { - const response = await this.apiService.nativeFetch(new Request(url)); - if (!response.ok) { - throw new Error("Fetch failed"); - } + this.logService.debug(`[PhishingDataService] Fetching checksum from: ${checksumUrl}`); - const downloadStream = response.body!; - // Pipe through CompressionStream while it's downloading - const compressedStream = downloadStream.pipeThrough(new CompressionStream("gzip")); - // Convert to ArrayBuffer - const buffer = await new Response(compressedStream).arrayBuffer(); - const bytes = new Uint8Array(buffer); + try { + const response = await this.apiService.nativeFetch(new Request(checksumUrl)); + if (!response.ok) { + throw new Error( + `[PhishingDataService] Failed to fetch checksum: ${response.status} ${response.statusText}`, + ); + } - // Return as Base64 for storage - return (bytes as any).toBase64 ? (bytes as any).toBase64() : this._uint8ToBase64Fallback(bytes); + return await response.text(); + } catch (error) { + this.logService.error( + `[PhishingDataService] Checksum fetch failed from ${checksumUrl}`, + error, + ); + throw error; + } } - private async fetchText(url: string) { + // [FIXME] Pull fetches into api service + private async fetchToday(url: string) { const response = await this.apiService.nativeFetch(new Request(url)); if (!response.ok) { @@ -258,171 +278,196 @@ export class PhishingDataService { private getTestWebAddresses() { const flag = devFlagEnabled("testPhishingUrls"); + // Normalize URLs by converting to URL object and back to ensure consistent format (e.g., trailing slashes) + const testWebAddresses: string[] = [ + new URL("http://phishing.testcategory.com").href, + new URL("https://phishing.testcategory.com").href, + new URL("https://phishing.testcategory.com/block").href, + ]; if (!flag) { - return []; + return testWebAddresses; } const webAddresses = devFlagValue("testPhishingUrls") as unknown[]; if (webAddresses && webAddresses instanceof Array) { this.logService.debug( - "[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:", + "[PhishingDataService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:", webAddresses, ); - return webAddresses as string[]; + // Normalize dev flag URLs as well, filtering out invalid ones + const normalizedDevAddresses = (webAddresses as string[]) + .filter((addr) => { + try { + new URL(addr); + return true; + } catch { + this.logService.warning( + `[PhishingDataService] Invalid test URL in dev flag, skipping: ${addr}`, + ); + return false; + } + }) + .map((addr) => new URL(addr).href); + return testWebAddresses.concat(normalizedDevAddresses); } - return []; + return testWebAddresses; } - // Runs the update flow in the background and retries up to 3 times on failure - private async _backgroundUpdate(previous: PhishingDataMeta | null): Promise { - this.logService.info(`[PhishingDataService] Update web addresses triggered...`); - const phishingMeta: PhishingDataMeta = previous ?? { - timestamp: 0, - checksum: "", - applicationVersion: "", - }; - // Start time for logging performance of update - const startTime = Date.now(); - const maxAttempts = 3; - const delayMs = 5 * 60 * 1000; // 5 minutes + private _getUpdatedMeta(): Observable { + return defer(() => { + const now = Date.now(); - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const next = await this.getNextWebAddresses(phishingMeta); - if (!next) { - return; // No update needed - } + return forkJoin({ + applicationVersion: from(this.platformUtilsService.getApplicationVersion()), + remoteChecksum: from(this.fetchPhishingChecksum(this.resourceType)), + }).pipe( + map(({ applicationVersion, remoteChecksum }) => { + return { + checksum: remoteChecksum, + timestamp: now, + applicationVersion, + }; + }), + ); + }); + } - if (next.meta) { - await this._phishingMetaState.update(() => next!.meta!); - } - if (next.blob) { - await this._phishingBlobState.update(() => next!.blob!); - this._loadBlobToMemory(); - } + // Streams the full phishing data set and saves it to IndexedDB + private _updateFullDataSet() { + const resource = getPhishingResources(this.resourceType); - // Performance logging - const elapsed = Date.now() - startTime; - this.logService.info(`[PhishingDataService] Phishing data cache updated in ${elapsed}ms`); - } catch (err) { - this.logService.error( - `[PhishingDataService] Unable to update web addresses. Attempt ${attempt}.`, - err, - ); - if (attempt < maxAttempts) { - await new Promise((res) => setTimeout(res, delayMs)); - } else { - const elapsed = Date.now() - startTime; - this.logService.error( - `[PhishingDataService] Retries unsuccessful after ${elapsed}ms. Unable to update web addresses.`, - err, + if (!resource?.remoteUrl) { + return throwError(() => new Error("Invalid resource URL")); + } + + this.logService.info(`[PhishingDataService] Starting FULL update using ${resource.remoteUrl}`); + return from(this.apiService.nativeFetch(new Request(resource.remoteUrl))).pipe( + switchMap((response) => { + if (!response.ok || !response.body) { + return throwError( + () => + new Error( + `[PhishingDataService] Full fetch failed: ${response.status}, ${response.statusText}`, + ), ); } - } - } + + return from(this.indexedDbService.saveUrlsFromStream(response.body)); + }), + catchError((err: unknown) => { + this.logService.error( + `[PhishingDataService] Full dataset update failed using primary source ${err}`, + ); + this.logService.warning( + `[PhishingDataService] Falling back to: ${resource.fallbackUrl} (Note: Fallback data may be less up-to-date)`, + ); + // Try fallback URL + return from(this.apiService.nativeFetch(new Request(resource.fallbackUrl))).pipe( + switchMap((fallbackResponse) => { + if (!fallbackResponse.ok || !fallbackResponse.body) { + return throwError( + () => + new Error( + `[PhishingDataService] Fallback fetch failed: ${fallbackResponse.status}, ${fallbackResponse.statusText}`, + ), + ); + } + + return from(this.indexedDbService.saveUrlsFromStream(fallbackResponse.body)); + }), + catchError((fallbackError: unknown) => { + this.logService.error(`[PhishingDataService] Fallback source failed`); + return throwError(() => fallbackError); + }), + ); + }), + ); } - // Sets up the load pipeline to load the blob into memory when triggered - private _setupLoadPipeline(): void { - this._loadTrigger$ - .pipe( - switchMap(() => - this._phishingBlobState.state$.pipe( - first(), - switchMap((blobBase64) => { - if (!blobBase64) { - return of(undefined); - } - // Note: _decompressString wraps a promise that cannot be aborted - // If performance improvements are needed, consider migrating to a cancellable approach - return from(this._decompressString(blobBase64)).pipe( - map((text) => { - const lines = text.split(/\r?\n/); - const newWebAddressesSet = new Set(lines); - this._testWebAddresses.forEach((a) => newWebAddressesSet.add(a)); - this._webAddressesSet = new Set(newWebAddressesSet); - this.logService.info( - `[PhishingDataService] loaded ${this._webAddressesSet.size} addresses into memory from blob`, - ); - }), + private _updateDailyDataSet() { + this.logService.info("[PhishingDataService] Starting DAILY update..."); + + const todayUrl = getPhishingResources(this.resourceType)?.todayUrl; + if (!todayUrl) { + return throwError(() => new Error("Today URL missing")); + } + + return from(this.fetchToday(todayUrl)).pipe( + switchMap((lines) => from(this.indexedDbService.addUrls(lines))), + ); + } + + private _backgroundUpdate( + previous: PhishingDataMeta | null, + ): Observable { + // Use defer to restart timer if retry is activated + return defer(() => { + const startTime = Date.now(); + this.logService.info(`[PhishingDataService] Update triggered...`); + + // Get updated meta info + return this._getUpdatedMeta().pipe( + // Update full data set if application version or checksum changed + concatMap((newMeta) => + iif( + () => { + const appVersionChanged = newMeta.applicationVersion !== previous?.applicationVersion; + const checksumChanged = newMeta.checksum !== previous?.checksum; + + this.logService.info( + `[PhishingDataService] Checking if full update is needed: appVersionChanged=${appVersionChanged}, checksumChanged=${checksumChanged}`, ); - }), - catchError((err: unknown) => { - this.logService.error("[PhishingDataService] Failed to load blob into memory", err); - return of(undefined); - }), + return appVersionChanged || checksumChanged; + }, + this._updateFullDataSet().pipe(map(() => ({ meta: newMeta, updated: true }))), + of({ meta: newMeta, updated: false }), ), ), - catchError((err: unknown) => { - this.logService.error("[PhishingDataService] Load pipeline failed", err); - return of(undefined); + // Update daily data set if last update was more than UPDATE_INTERVAL_DURATION ago + concatMap((result) => + iif( + () => { + const isCacheExpired = + Date.now() - (previous?.timestamp ?? 0) > this.UPDATE_INTERVAL_DURATION; + return isCacheExpired; + }, + this._updateDailyDataSet().pipe(map(() => ({ meta: result.meta, updated: true }))), + of(result), + ), + ), + concatMap((result) => { + if (!result.updated) { + this.logService.debug(`[PhishingDataService] No update needed, metadata unchanged`); + return of(previous); + } + + this.logService.debug(`[PhishingDataService] Updated phishing meta data:`, result.meta); + return from(this._phishingMetaState.update(() => result.meta)).pipe( + tap(() => { + const elapsed = Date.now() - startTime; + this.logService.info(`[PhishingDataService] Updated data set in ${elapsed}ms`); + }), + ); }), - takeUntil(this._destroy$), - share(), - ) - .subscribe(); - } - - // [FIXME] Move compression helpers to a shared utils library - // to separate from phishing data service. - // ------------------------- Blob and Compression Handling ------------------------- - private async _compressString(input: string): Promise { - try { - const stream = new Blob([input]).stream().pipeThrough(new CompressionStream("gzip")); - - const compressedBuffer = await new Response(stream).arrayBuffer(); - const bytes = new Uint8Array(compressedBuffer); - - // Modern browsers support direct toBase64 conversion - // For older support, use fallback - return (bytes as any).toBase64 - ? (bytes as any).toBase64() - : this._uint8ToBase64Fallback(bytes); - } catch (err) { - this.logService.error("[PhishingDataService] Compression failed", err); - return btoa(encodeURIComponent(input)); - } - } - - private async _decompressString(base64: string): Promise { - try { - // Modern browsers support direct toBase64 conversion - // For older support, use fallback - const bytes = (Uint8Array as any).fromBase64 - ? (Uint8Array as any).fromBase64(base64) - : this._base64ToUint8Fallback(base64); - if (bytes == null) { - throw new Error("Base64 decoding resulted in null"); - } - const byteResponse = new Response(bytes); - if (!byteResponse.body) { - throw new Error("Response body is null"); - } - const stream = byteResponse.body.pipeThrough(new DecompressionStream("gzip")); - const streamResponse = new Response(stream); - return await streamResponse.text(); - } catch (err) { - this.logService.error("[PhishingDataService] Decompression failed", err); - return decodeURIComponent(atob(base64)); - } - } - - // Trigger a load of the blob into memory - private _loadBlobToMemory(): void { - this._loadTrigger$.next(); - } - private _uint8ToBase64Fallback(bytes: Uint8Array): string { - const CHUNK_SIZE = 0x8000; // 32KB chunks - let binary = ""; - for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { - const chunk = bytes.subarray(i, i + CHUNK_SIZE); - binary += String.fromCharCode.apply(null, chunk as any); - } - return btoa(binary); - } - - private _base64ToUint8Fallback(base64: string): Uint8Array { - const binary = atob(base64); - return Uint8Array.from(binary, (c) => c.charCodeAt(0)); + retry({ + count: 2, // Total 3 attempts (initial + 2 retries) + delay: (error, retryCount) => { + this.logService.error( + `[PhishingDataService] Attempt ${retryCount} failed. Retrying in 5m...`, + error, + ); + return timer(5 * 60 * 1000); // Wait 5 mins before next attempt + }, + }), + catchError((err: unknown) => { + const elapsed = Date.now() - startTime; + this.logService.error( + `[PhishingDataService] Retries unsuccessful after ${elapsed}ms.`, + err, + ); + return of(previous); + }), + ); + }); } } diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index 815007e1d4c..6ca5bad8942 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -1,10 +1,10 @@ import { - concatMap, distinctUntilChanged, EMPTY, filter, map, merge, + mergeMap, Subject, switchMap, tap, @@ -43,6 +43,7 @@ export class PhishingDetectionService { private static _tabUpdated$ = new Subject(); private static _ignoredHostnames = new Set(); private static _didInit = false; + private static _activeSearchCount = 0; static initialize( logService: LogService, @@ -63,7 +64,7 @@ export class PhishingDetectionService { tap((message) => logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`), ), - concatMap(async (message) => { + mergeMap(async (message) => { const url = new URL(message.url); this._ignoredHostnames.add(url.hostname); await BrowserApi.navigateTabToUrl(message.tabId, url); @@ -88,23 +89,40 @@ export class PhishingDetectionService { prev.ignored === curr.ignored, ), tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)), - concatMap(async ({ tabId, url, ignored }) => { - if (ignored) { - // The next time this host is visited, block again - this._ignoredHostnames.delete(url.hostname); - return; - } - const isPhishing = await phishingDataService.isPhishingWebAddress(url); - if (!isPhishing) { - return; - } - - const phishingWarningPage = new URL( - BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") + - `?phishingUrl=${url.toString()}`, + // Use mergeMap for parallel processing - each tab check runs independently + // Concurrency limit of 5 prevents overwhelming IndexedDB + mergeMap(async ({ tabId, url, ignored }) => { + this._activeSearchCount++; + const searchId = `${tabId}-${Date.now()}`; + logService.debug( + `[PhishingDetectionService] Search STARTED [${searchId}] for ${url.href} (active: ${this._activeSearchCount}/5)`, ); - await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage); - }), + const startTime = performance.now(); + + try { + if (ignored) { + // The next time this host is visited, block again + this._ignoredHostnames.delete(url.hostname); + return; + } + const isPhishing = await phishingDataService.isPhishingWebAddress(url); + if (!isPhishing) { + return; + } + + const phishingWarningPage = new URL( + BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") + + `?phishingUrl=${url.toString()}`, + ); + await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage); + } finally { + this._activeSearchCount--; + const duration = (performance.now() - startTime).toFixed(2); + logService.debug( + `[PhishingDetectionService] Search FINISHED [${searchId}] for ${url.href} in ${duration}ms (active: ${this._activeSearchCount}/5)`, + ); + } + }, 5), ); const onCancelCommand$ = messageListener diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts index 75bd634b1fc..98835a5b366 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts @@ -215,6 +215,86 @@ describe("PhishingIndexedDbService", () => { }); }); + describe("addUrls", () => { + it("appends URLs to IndexedDB without clearing", async () => { + // Pre-populate store with existing data + mockStore.set("https://existing.com", { url: "https://existing.com" }); + + const urls = ["https://phishing.com", "https://malware.net"]; + const result = await service.addUrls(urls); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readwrite"); + expect(mockObjectStore.clear).not.toHaveBeenCalled(); + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + // Existing data should still be present + expect(mockStore.has("https://existing.com")).toBe(true); + expect(mockStore.size).toBe(3); + expect(mockDb.close).toHaveBeenCalled(); + }); + + it("handles empty array without clearing", async () => { + mockStore.set("https://existing.com", { url: "https://existing.com" }); + + const result = await service.addUrls([]); + + expect(result).toBe(true); + expect(mockObjectStore.clear).not.toHaveBeenCalled(); + expect(mockStore.has("https://existing.com")).toBe(true); + }); + + it("trims whitespace from URLs", async () => { + const urls = [" https://example.com ", "\nhttps://test.org\n"]; + + await service.addUrls(urls); + + expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://example.com" }); + expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://test.org" }); + }); + + it("skips empty lines", async () => { + const urls = ["https://example.com", "", " ", "https://test.org"]; + + await service.addUrls(urls); + + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + }); + + it("handles duplicate URLs via upsert", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + + const urls = [ + "https://example.com", // Already exists + "https://test.org", + ]; + + const result = await service.addUrls(urls); + + expect(result).toBe(true); + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + expect(mockStore.size).toBe(2); + }); + + it("logs error and returns false on failure", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const result = await service.addUrls(["https://test.com"]); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Add failed", + expect.any(Error), + ); + }); + }); + describe("hasUrl", () => { it("returns true for existing URL", async () => { mockStore.set("https://example.com", { url: "https://example.com" }); @@ -355,6 +435,89 @@ describe("PhishingIndexedDbService", () => { }); }); + describe("findMatchingUrl", () => { + it("returns true when matcher finds a match", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + mockStore.set("https://phishing.net", { url: "https://phishing.net" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + + const matcher = (url: string) => url.includes("phishing"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readonly"); + expect(mockObjectStore.openCursor).toHaveBeenCalled(); + }); + + it("returns false when no URLs match", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + + const matcher = (url: string) => url.includes("notfound"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(false); + }); + + it("returns false when store is empty", async () => { + const matcher = (url: string) => url.includes("anything"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(false); + }); + + it("exits early on first match without iterating all records", async () => { + mockStore.set("https://match1.com", { url: "https://match1.com" }); + mockStore.set("https://match2.com", { url: "https://match2.com" }); + mockStore.set("https://match3.com", { url: "https://match3.com" }); + + const matcherCallCount = jest + .fn() + .mockImplementation((url: string) => url.includes("match2")); + await service.findMatchingUrl(matcherCallCount); + + // Matcher should be called for match1.com and match2.com, but NOT match3.com + // because it exits early on first match + expect(matcherCallCount).toHaveBeenCalledWith("https://match1.com"); + expect(matcherCallCount).toHaveBeenCalledWith("https://match2.com"); + expect(matcherCallCount).not.toHaveBeenCalledWith("https://match3.com"); + expect(matcherCallCount).toHaveBeenCalledTimes(2); + }); + + it("supports complex matcher logic", async () => { + mockStore.set("https://example.com/path", { url: "https://example.com/path" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + mockStore.set("https://phishing.net/login", { url: "https://phishing.net/login" }); + + const matcher = (url: string) => { + return url.includes("phishing") && url.includes("login"); + }; + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(true); + }); + + it("returns false on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const matcher = (url: string) => url.includes("test"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Cursor search failed", + expect.any(Error), + ); + }); + }); + describe("database initialization", () => { it("creates object store with keyPath on upgrade", async () => { mockDb.objectStoreNames.contains.mockReturnValue(false); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts index 099839a38d9..ea4b7987607 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts @@ -53,6 +53,9 @@ export class PhishingIndexedDbService { * @returns `true` if save succeeded, `false` on error */ async saveUrls(urls: string[]): Promise { + this.logService.debug( + `[PhishingIndexedDbService] Clearing and saving ${urls.length} to the store...`, + ); let db: IDBDatabase | null = null; try { db = await this.openDatabase(); @@ -67,6 +70,29 @@ export class PhishingIndexedDbService { } } + /** + * Adds an array of phishing URLs to IndexedDB. + * Appends to existing data without clearing. + * + * @param urls - Array of phishing URLs to add + * @returns `true` if add succeeded, `false` on error + */ + async addUrls(urls: string[]): Promise { + this.logService.debug(`[PhishingIndexedDbService] Adding ${urls.length} to the store...`); + + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + await this.saveChunked(db, urls); + return true; + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Add failed", error); + return false; + } finally { + db?.close(); + } + } + /** * Saves URLs in chunks to prevent transaction timeouts and UI freezes. */ @@ -100,6 +126,8 @@ export class PhishingIndexedDbService { * @returns `true` if URL exists, `false` if not found or on error */ async hasUrl(url: string): Promise { + this.logService.debug(`[PhishingIndexedDbService] Checking if store contains ${url}...`); + let db: IDBDatabase | null = null; try { db = await this.openDatabase(); @@ -130,6 +158,8 @@ export class PhishingIndexedDbService { * @returns Array of all stored URLs, or empty array on error */ async loadAllUrls(): Promise { + this.logService.debug("[PhishingIndexedDbService] Loading all urls from store..."); + let db: IDBDatabase | null = null; try { db = await this.openDatabase(); @@ -165,6 +195,60 @@ export class PhishingIndexedDbService { }); } + /** + * Checks if any URL in the database matches the given matcher function. + * Uses a cursor to iterate through records without loading all into memory. + * Returns immediately on first match for optimal performance. + * + * @param matcher - Function that tests each URL and returns true if it matches + * @returns `true` if any URL matches, `false` if none match or on error + */ + async findMatchingUrl(matcher: (url: string) => boolean): Promise { + this.logService.debug("[PhishingIndexedDbService] Searching for matching URL with cursor..."); + + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + return await this.cursorSearch(db, matcher); + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Cursor search failed", error); + return false; + } finally { + db?.close(); + } + } + + /** + * Performs cursor-based search through all URLs. + * Tests each URL with the matcher without accumulating records in memory. + */ + private cursorSearch(db: IDBDatabase, matcher: (url: string) => boolean): Promise { + return new Promise((resolve, reject) => { + const req = db + .transaction(this.STORE_NAME, "readonly") + .objectStore(this.STORE_NAME) + .openCursor(); + req.onerror = () => reject(req.error); + req.onsuccess = (e) => { + const cursor = (e.target as IDBRequest).result; + if (cursor) { + const url = (cursor.value as PhishingUrlRecord).url; + // Test the URL immediately without accumulating in memory + if (matcher(url)) { + // Found a match + resolve(true); + return; + } + // No match, continue to next record + cursor.continue(); + } else { + // Reached end of records without finding a match + resolve(false); + } + }; + }); + } + /** * Saves phishing URLs directly from a stream. * Processes data incrementally to minimize memory usage. @@ -173,11 +257,16 @@ export class PhishingIndexedDbService { * @returns `true` if save succeeded, `false` on error */ async saveUrlsFromStream(stream: ReadableStream): Promise { + this.logService.debug("[PhishingIndexedDbService] Saving urls to the store from stream..."); + let db: IDBDatabase | null = null; try { db = await this.openDatabase(); await this.clearStore(db); await this.processStream(db, stream); + this.logService.info( + "[PhishingIndexedDbService] Finished saving urls to the store from stream.", + ); return true; } catch (error) { this.logService.error("[PhishingIndexedDbService] Stream save failed", error); diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts index 7678b65d29e..ecdb899b9a7 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts @@ -14,7 +14,7 @@ import { BiometricsStatus, BiometricStateService, } from "@bitwarden/key-management"; -import { UnlockOptions } from "@bitwarden/key-management-ui"; +import { UnlockOptions, WebAuthnPrfUnlockService } from "@bitwarden/key-management-ui"; import { BrowserApi } from "../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; @@ -34,6 +34,7 @@ describe("ExtensionLockComponentService", () => { let vaultTimeoutSettingsService: MockProxy; let routerService: MockProxy; let biometricStateService: MockProxy; + let webAuthnPrfUnlockService: MockProxy; beforeEach(() => { userDecryptionOptionsService = mock(); @@ -43,37 +44,21 @@ describe("ExtensionLockComponentService", () => { vaultTimeoutSettingsService = mock(); routerService = mock(); biometricStateService = mock(); + webAuthnPrfUnlockService = mock(); TestBed.configureTestingModule({ providers: [ - ExtensionLockComponentService, { - provide: UserDecryptionOptionsServiceAbstraction, - useValue: userDecryptionOptionsService, - }, - { - provide: PlatformUtilsService, - useValue: platformUtilsService, - }, - { - provide: BiometricsService, - useValue: biometricsService, - }, - { - provide: PinServiceAbstraction, - useValue: pinService, - }, - { - provide: VaultTimeoutSettingsService, - useValue: vaultTimeoutSettingsService, - }, - { - provide: BrowserRouterService, - useValue: routerService, - }, - { - provide: BiometricStateService, - useValue: biometricStateService, + provide: ExtensionLockComponentService, + useFactory: () => + new ExtensionLockComponentService( + userDecryptionOptionsService, + biometricsService, + pinService, + biometricStateService, + routerService, + webAuthnPrfUnlockService, + ), }, ], }); @@ -212,6 +197,9 @@ describe("ExtensionLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -234,6 +222,9 @@ describe("ExtensionLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -256,6 +247,9 @@ describe("ExtensionLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -278,6 +272,9 @@ describe("ExtensionLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -300,6 +297,9 @@ describe("ExtensionLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.UnlockNeeded, }, + prf: { + enabled: false, + }, }, ], [ @@ -322,6 +322,9 @@ describe("ExtensionLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledInConnectedDesktopApp, }, + prf: { + enabled: false, + }, }, ], [ @@ -344,6 +347,9 @@ describe("ExtensionLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.HardwareUnavailable, }, + prf: { + enabled: false, + }, }, ], ]; @@ -374,6 +380,9 @@ describe("ExtensionLockComponentService", () => { // PIN pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable); + // PRF + webAuthnPrfUnlockService.isPrfUnlockAvailable.mockResolvedValue(false); + const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); expect(unlockOptions).toEqual(expectedOutput); diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts index 9f137d694a9..5e6e564bbc2 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts @@ -1,6 +1,3 @@ -// FIXME (PM-22628): angular imports are forbidden in background -// eslint-disable-next-line no-restricted-imports -import { inject } from "@angular/core"; import { combineLatest, defer, firstValueFrom, map, Observable } from "rxjs"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; @@ -11,7 +8,11 @@ import { BiometricsStatus, BiometricStateService, } from "@bitwarden/key-management"; -import { LockComponentService, UnlockOptions } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + UnlockOptions, + WebAuthnPrfUnlockService, +} from "@bitwarden/key-management-ui"; import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; import { BrowserApi } from "../../../platform/browser/browser-api"; @@ -21,11 +22,14 @@ import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service"; export class ExtensionLockComponentService implements LockComponentService { - private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction); - private readonly biometricsService = inject(BiometricsService); - private readonly pinService = inject(PinServiceAbstraction); - private readonly routerService = inject(BrowserRouterService); - private readonly biometricStateService = inject(BiometricStateService); + constructor( + private readonly userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private readonly biometricsService: BiometricsService, + private readonly pinService: PinServiceAbstraction, + private readonly biometricStateService: BiometricStateService, + private readonly routerService: BrowserRouterService, + private readonly webAuthnPrfUnlockService: WebAuthnPrfUnlockService, + ) {} getPreviousUrl(): string | null { return this.routerService.getPreviousUrl() ?? null; @@ -81,8 +85,12 @@ export class ExtensionLockComponentService implements LockComponentService { }), this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), defer(() => this.pinService.isPinDecryptionAvailable(userId)), + defer(async () => { + const available = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(userId); + return { available }; + }), ]).pipe( - map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable]) => { + map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable, prfUnlockInfo]) => { const unlockOpts: UnlockOptions = { masterPassword: { enabled: userDecryptionOptions?.hasMasterPassword, @@ -94,6 +102,9 @@ export class ExtensionLockComponentService implements LockComponentService { enabled: biometricsStatus === BiometricsStatus.Available, biometricsStatus: biometricsStatus, }, + prf: { + enabled: prfUnlockInfo.available, + }, }; return unlockOpts; }), diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 26add57d1ae..ce5311f848a 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": "Bitwarden", - "version": "2025.12.1", + "version": "2026.1.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 64d182ebd3d..9cb77aa3040 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": "Bitwarden", - "version": "2025.12.1", + "version": "2026.1.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/platform/popup/layout/popup-size.service.ts b/apps/browser/src/platform/popup/layout/popup-size.service.ts index ff3f09d0d01..4c0c901270e 100644 --- a/apps/browser/src/platform/popup/layout/popup-size.service.ts +++ b/apps/browser/src/platform/popup/layout/popup-size.service.ts @@ -37,7 +37,7 @@ export class PopupSizeService { /** Begin listening for state changes */ async init() { this.width$.subscribe((width: PopupWidthOption) => { - PopupSizeService.setStyle(width); + void PopupSizeService.setStyle(width); localStorage.setItem(PopupSizeService.LocalStorageKey, width); }); } @@ -77,8 +77,9 @@ export class PopupSizeService { } } - private static setStyle(width: PopupWidthOption) { - if (!BrowserPopupUtils.inPopup(window)) { + private static async setStyle(width: PopupWidthOption) { + const isInTab = await BrowserPopupUtils.isInTab(); + if (!BrowserPopupUtils.inPopup(window) || isInTab) { return; } const pxWidth = PopupWidthOptions[width] ?? PopupWidthOptions.default; @@ -91,6 +92,6 @@ export class PopupSizeService { **/ static initBodyWidthFromLocalStorage() { const storedValue = localStorage.getItem(PopupSizeService.LocalStorageKey); - this.setStyle(storedValue as any); + void this.setStyle(storedValue as any); } } diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index f16d82d0810..24ff637c29b 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -2,8 +2,6 @@ import { inject, Inject, Injectable, DOCUMENT } from "@angular/core"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -30,8 +28,6 @@ export class InitService { private sdkLoadService: SdkLoadService, private viewCacheService: PopupViewCacheService, private readonly migrationRunner: MigrationRunner, - private configService: ConfigService, - private encryptService: EncryptService, @Inject(DOCUMENT) private document: Document, ) {} @@ -43,7 +39,6 @@ export class InitService { this.twoFactorService.init(); await this.viewCacheService.init(); await this.sizeService.init(); - this.encryptService.init(this.configService); const htmlEl = window.document.documentElement; this.themingService.applyThemeChangesTo(this.document); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7b207f0fac1..a8bfb23d83f 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -54,6 +54,7 @@ import { } from "@bitwarden/auto-confirm"; import { ExtensionAuthRequestAnsweringService } from "@bitwarden/browser/auth/services/auth-request-answering/extension-auth-request-answering.service"; import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service"; +import { BrowserRouterService } from "@bitwarden/browser/platform/popup/services/browser-router.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { @@ -71,6 +72,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; import { AutofillSettingsService, @@ -96,6 +98,7 @@ import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeoutService, @@ -160,12 +163,15 @@ import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { BiometricsService, + BiometricStateService, DefaultKeyService, KdfConfigService, KeyService, } from "@bitwarden/key-management"; import { LockComponentService, + WebAuthnPrfUnlockService, + DefaultWebAuthnPrfUnlockService, SessionTimeoutSettingsComponentService, } from "@bitwarden/key-management-ui"; import { DerivedStateProvider, GlobalStateProvider, StateProvider } from "@bitwarden/state"; @@ -572,15 +578,6 @@ const safeProviders: SafeProvider[] = [ useFactory: () => new Subject>>(), deps: [], }), - safeProvider({ - provide: MessageSender, - useFactory: (subject: Subject>>, logService: LogService) => - MessageSender.combine( - new SubjectMessageSender(subject), // For sending messages in the same context - new ChromeMessageSender(logService), // For sending messages to different contexts - ), - deps: [INTRAPROCESS_MESSAGING_SUBJECT, LogService], - }), safeProvider({ provide: DISK_BACKUP_LOCAL_STORAGE, useFactory: (diskStorage: AbstractStorageService & ObservableStorageService) => @@ -604,7 +601,14 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: LockComponentService, useClass: ExtensionLockComponentService, - deps: [], + deps: [ + UserDecryptionOptionsServiceAbstraction, + BiometricsService, + PinServiceAbstraction, + BiometricStateService, + BrowserRouterService, + WebAuthnPrfUnlockService, + ], }), // TODO: PM-18182 - Refactor component services into lazy loaded modules safeProvider({ @@ -653,6 +657,21 @@ const safeProviders: SafeProvider[] = [ AccountServiceAbstraction, ], }), + safeProvider({ + provide: WebAuthnPrfUnlockService, + useClass: DefaultWebAuthnPrfUnlockService, + deps: [ + WebAuthnLoginPrfKeyServiceAbstraction, + KeyService, + UserDecryptionOptionsServiceAbstraction, + EncryptService, + EnvironmentService, + PlatformUtilsService, + WINDOW, + LogService, + ConfigService, + ], + }), safeProvider({ provide: AnimationControlService, useClass: DefaultAnimationControlService, diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.html b/apps/browser/src/tools/popup/send-v2/send-v2.component.html index 47ecd7564dc..48295fda35d 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.html @@ -1,4 +1,4 @@ - + diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts index dfbfabf8d5e..dc4b935c6c8 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -11,7 +11,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -110,7 +109,6 @@ describe("SendV2Component", () => { provide: BillingAccountProfileStateService, useValue: { hasPremiumFromAnySource$: of(false) }, }, - { provide: ConfigService, useValue: mock() }, { provide: EnvironmentService, useValue: mock() }, { provide: LogService, useValue: mock() }, { provide: PlatformUtilsService, useValue: mock() }, diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index f36a475a805..8c1edee79dc 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -11,8 +11,6 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; @@ -84,30 +82,17 @@ export class SendV2Component implements OnDestroy { protected listState: SendState | null = null; protected sends$ = this.sendItemsService.filteredAndSortedSends$; - private skeletonFeatureFlag$ = this.configService.getFeatureFlag$( - FeatureFlag.VaultLoadingSkeletons, - ); protected sendsLoading$ = this.sendItemsService.loading$.pipe( distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }), ); - /** Spinner Loading State */ - protected showSpinnerLoaders$ = combineLatest([ - this.sendsLoading$, - this.skeletonFeatureFlag$, - ]).pipe(map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled)); - /** Skeleton Loading State */ protected showSkeletonsLoaders$ = combineLatest([ this.sendsLoading$, this.searchService.isSendSearching$, - this.skeletonFeatureFlag$, ]).pipe( - map( - ([loading, cipherSearching, skeletonsEnabled]) => - (loading || cipherSearching) && skeletonsEnabled, - ), + map(([loading, cipherSearching]) => loading || cipherSearching), distinctUntilChanged(), skeletonLoadingDelay(), ); @@ -128,7 +113,6 @@ export class SendV2Component implements OnDestroy { protected sendListFiltersService: SendListFiltersService, private policyService: PolicyService, private accountService: AccountService, - private configService: ConfigService, private searchService: SearchService, ) { combineLatest([ diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts index 37c4804e600..ca73a7332ee 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts @@ -4,7 +4,6 @@ import { FormsModule } from "@angular/forms"; import { BehaviorSubject } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; import { SearchModule } from "@bitwarden/components"; @@ -20,7 +19,6 @@ describe("VaultV2SearchComponent", () => { const searchText$ = new BehaviorSubject(""); const loading$ = new BehaviorSubject(false); - const featureFlag$ = new BehaviorSubject(true); const applyFilter = jest.fn(); const createComponent = () => { @@ -31,7 +29,6 @@ describe("VaultV2SearchComponent", () => { beforeEach(async () => { applyFilter.mockClear(); - featureFlag$.next(true); await TestBed.configureTestingModule({ imports: [VaultV2SearchComponent, CommonModule, SearchModule, JslibModule, FormsModule], @@ -49,12 +46,6 @@ describe("VaultV2SearchComponent", () => { loading$, }, }, - { - provide: ConfigService, - useValue: { - getFeatureFlag$: jest.fn(() => featureFlag$), - }, - }, { provide: I18nService, useValue: { t: (key: string) => key } }, ], }).compileComponents(); @@ -70,91 +61,55 @@ describe("VaultV2SearchComponent", () => { }); describe("debouncing behavior", () => { - describe("when feature flag is enabled", () => { - beforeEach(() => { - featureFlag$.next(true); - createComponent(); - }); - - it("debounces search text changes when not loading", fakeAsync(() => { - loading$.next(false); - - component.searchText = "test"; - component.onSearchTextChanged(); - - expect(applyFilter).not.toHaveBeenCalled(); - - tick(SearchTextDebounceInterval); - - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - - it("should not debounce search text changes when loading", fakeAsync(() => { - loading$.next(true); - - component.searchText = "test"; - component.onSearchTextChanged(); - - tick(0); - - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - - it("cancels previous debounce when new text is entered", fakeAsync(() => { - loading$.next(false); - - component.searchText = "test"; - component.onSearchTextChanged(); - - tick(SearchTextDebounceInterval / 2); - - component.searchText = "test2"; - component.onSearchTextChanged(); - - tick(SearchTextDebounceInterval / 2); - - expect(applyFilter).not.toHaveBeenCalled(); - - tick(SearchTextDebounceInterval / 2); - - expect(applyFilter).toHaveBeenCalledWith("test2"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); + beforeEach(() => { + createComponent(); }); - describe("when feature flag is disabled", () => { - beforeEach(() => { - featureFlag$.next(false); - createComponent(); - }); + it("debounces search text changes when not loading", fakeAsync(() => { + loading$.next(false); - it("debounces search text changes", fakeAsync(() => { - component.searchText = "test"; - component.onSearchTextChanged(); + component.searchText = "test"; + component.onSearchTextChanged(); - expect(applyFilter).not.toHaveBeenCalled(); + expect(applyFilter).not.toHaveBeenCalled(); - tick(SearchTextDebounceInterval); + tick(SearchTextDebounceInterval); - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); - it("ignores loading state and always debounces", fakeAsync(() => { - loading$.next(true); + it("should not debounce search text changes when loading", fakeAsync(() => { + loading$.next(true); - component.searchText = "test"; - component.onSearchTextChanged(); + component.searchText = "test"; + component.onSearchTextChanged(); - expect(applyFilter).not.toHaveBeenCalled(); + tick(0); - tick(SearchTextDebounceInterval); + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - }); + it("cancels previous debounce when new text is entered", fakeAsync(() => { + loading$.next(false); + + component.searchText = "test"; + component.onSearchTextChanged(); + + tick(SearchTextDebounceInterval / 2); + + component.searchText = "test2"; + component.onSearchTextChanged(); + + tick(SearchTextDebounceInterval / 2); + + expect(applyFilter).not.toHaveBeenCalled(); + + tick(SearchTextDebounceInterval / 2); + + expect(applyFilter).toHaveBeenCalledWith("test2"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); }); }); 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 154cd49c5a3..3419bd30ea0 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 @@ -7,17 +7,13 @@ import { Subscription, combineLatest, debounce, - debounceTime, distinctUntilChanged, filter, map, - switchMap, timer, } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; import { SearchModule } from "@bitwarden/components"; @@ -40,7 +36,6 @@ export class VaultV2SearchComponent { constructor( private vaultPopupItemsService: VaultPopupItemsService, private vaultPopupLoadingService: VaultPopupLoadingService, - private configService: ConfigService, private ngZone: NgZone, ) { this.subscribeToLatestSearchText(); @@ -63,31 +58,19 @@ export class VaultV2SearchComponent { } subscribeToApplyFilter(): void { - this.configService - .getFeatureFlag$(FeatureFlag.VaultLoadingSkeletons) + combineLatest([this.searchText$, this.loading$]) .pipe( - switchMap((enabled) => { - if (!enabled) { - return this.searchText$.pipe( - debounceTime(SearchTextDebounceInterval), - distinctUntilChanged(), - ); - } - - return combineLatest([this.searchText$, this.loading$]).pipe( - debounce(([_, isLoading]) => { - // If loading apply immediately to avoid stale searches. - // After loading completes, debounce to avoid excessive searches. - const delayTime = isLoading ? 0 : SearchTextDebounceInterval; - return timer(delayTime); - }), - distinctUntilChanged( - ([prevText, prevLoading], [newText, newLoading]) => - prevText === newText && prevLoading === newLoading, - ), - map(([text, _]) => text), - ); + debounce(([_, isLoading]) => { + // If loading apply immediately to avoid stale searches. + // After loading completes, debounce to avoid excessive searches. + const delayTime = isLoading ? 0 : SearchTextDebounceInterval; + return timer(delayTime); }), + distinctUntilChanged( + ([prevText, prevLoading], [newText, newLoading]) => + prevText === newText && prevLoading === newLoading, + ), + map(([text, _]) => text), takeUntilDestroyed(), ) .subscribe((text) => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index 34454371f21..20871b4b134 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -1,4 +1,4 @@ - + @@ -8,37 +8,28 @@ - -
- - {{ "yourVaultIsEmpty" | i18n }} - -

- {{ "emptyVaultDescription" | i18n }} -

-
- - {{ "newLogin" | i18n }} - -
-
-
- - @if (skeletonFeatureFlag$ | async) { - - + @if (vaultState === VaultStateEnum.Empty) { + +
+ + {{ "yourVaultIsEmpty" | i18n }} + +

+ {{ "emptyVaultDescription" | i18n }} +

+
+ + {{ "newLogin" | i18n }} + +
+
- } @else { - } - - - - - - - - - @if (skeletonFeatureFlag$ | async) { - - + @if (vaultState === null) { + + @if (!(loading$ | async)) { + + + + } - } @else { - }
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index 2c94d9c226b..e3b72c3319f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, input, NO_ERRORS_SCHEMA } from "@angular/core"; import { TestBed, fakeAsync, flush, tick } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; import { ActivatedRoute, Router } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; import { mock } from "jest-mock-extended"; @@ -243,6 +244,7 @@ describe("VaultV2Component", () => { await TestBed.configureTestingModule({ imports: [VaultV2Component, RouterTestingModule], providers: [ + provideNoopAnimations(), { provide: VaultPopupItemsService, useValue: itemsSvc }, { provide: VaultPopupListFiltersService, useValue: filtersSvc }, { provide: VaultPopupScrollPositionService, useValue: scrollSvc }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 4678e2733eb..c58b7b20d2f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -158,10 +158,6 @@ export class VaultV2Component implements OnInit, OnDestroy { }), ); - protected skeletonFeatureFlag$ = this.configService.getFeatureFlag$( - FeatureFlag.VaultLoadingSkeletons, - ); - protected premiumSpotlightFeatureFlag$ = this.configService.getFeatureFlag$( FeatureFlag.BrowserPremiumSpotlight, ); @@ -216,20 +212,14 @@ export class VaultV2Component implements OnInit, OnDestroy { PremiumUpgradeDialogComponent.open(this.dialogService); } - /** When true, show spinner loading state */ - protected showSpinnerLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe( - map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled), - ); - /** When true, show skeleton loading state with debouncing to prevent flicker */ protected showSkeletonsLoaders$ = combineLatest([ this.loading$, this.searchService.isCipherSearching$, this.vaultItemsTransferService.transferInProgress$, - this.skeletonFeatureFlag$, ]).pipe( - map(([loading, cipherSearching, transferInProgress, skeletonsEnabled]) => { - return (loading || cipherSearching || transferInProgress) && skeletonsEnabled; + map(([loading, cipherSearching, transferInProgress]) => { + return loading || cipherSearching || transferInProgress; }), distinctUntilChanged(), skeletonLoadingDelay(), diff --git a/apps/cli/package.json b/apps/cli/package.json index 5174e324586..c80f79feff8 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": "2025.12.1", + "version": "2026.1.0", "keywords": [ "bitwarden", "password", @@ -81,7 +81,7 @@ "lowdb": "1.0.0", "lunr": "2.3.9", "multer": "2.0.2", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "open": "11.0.0", "papaparse": "5.5.3", diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 1cd7221ab70..7bb8da27040 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -1064,7 +1064,6 @@ export class ServiceContainer { this.containerService.attachToGlobal(global); await this.i18nService.init(); this.twoFactorService.init(); - this.encryptService.init(this.configService); // If a user has a BW_SESSION key stored in their env (not process.env.BW_SESSION), // this should set the user key to unlock the vault on init. diff --git a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs index 9aa2cea6e5e..ea723291fe3 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs @@ -24,12 +24,13 @@ pub fn get_supported_importers( let installed_browsers = T::get_installed_browsers().unwrap_or_default(); const IMPORTERS: &[(&str, &str)] = &[ + ("arccsv", "Arc"), + ("bravecsv", "Brave"), ("chromecsv", "Chrome"), ("chromiumcsv", "Chromium"), - ("bravecsv", "Brave"), + ("edgecsv", "Microsoft Edge"), ("operacsv", "Opera"), ("vivaldicsv", "Vivaldi"), - ("edgecsv", "Microsoft Edge"), ]; let supported: HashSet<&'static str> = @@ -91,6 +92,7 @@ mod tests { let map = get_supported_importers::(); let expected: HashSet = HashSet::from([ + "arccsv".to_string(), "chromecsv".to_string(), "chromiumcsv".to_string(), "bravecsv".to_string(), @@ -113,6 +115,7 @@ mod tests { fn macos_specific_loaders_match_const_array() { let map = get_supported_importers::(); let ids = [ + "arccsv", "chromecsv", "chromiumcsv", "bravecsv", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 174f3a22a23..aabf26e76bd 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": "2025.12.1", + "version": "2026.1.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/resources/linux-wrapper.sh b/apps/desktop/resources/linux-wrapper.sh index 3c5d16c3a3d..e1cb69274d7 100644 --- a/apps/desktop/resources/linux-wrapper.sh +++ b/apps/desktop/resources/linux-wrapper.sh @@ -12,9 +12,13 @@ if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ]; then export LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" fi +# A bug in Electron 39 (which now enables Wayland by default) causes a crash on +# systems using Wayland with hardware acceleration. Platform decided to +# configure Electron to use X11 (with an opt-out) until the upstream bug is +# fixed. The follow-up task is https://bitwarden.atlassian.net/browse/PM-31080. PARAMS="--enable-features=UseOzonePlatform,WaylandWindowDecorations --ozone-platform-hint=auto" -if [ "$USE_X11" = "true" ]; then - PARAMS="" +if [ "$USE_X11" != "false" ]; then + PARAMS="--ozone-platform=x11" fi $APP_PATH/bitwarden-app $PARAMS "$@" diff --git a/apps/desktop/src/app/layout/desktop-layout.component.html b/apps/desktop/src/app/layout/desktop-layout.component.html index f9921ac11ef..cb969f573fc 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.html +++ b/apps/desktop/src/app/layout/desktop-layout.component.html @@ -1,4 +1,4 @@ - + diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 17115825bf6..a6fd40cb998 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; @@ -54,7 +53,6 @@ export class InitService { private autotypeService: DesktopAutotypeService, private sdkLoadService: SdkLoadService, private biometricMessageHandlerService: BiometricMessageHandlerService, - private configService: ConfigService, @Inject(DOCUMENT) private document: Document, private readonly migrationRunner: MigrationRunner, ) {} @@ -65,7 +63,6 @@ export class InitService { await this.sshAgentService.init(); this.nativeMessagingService.init(); await this.migrationRunner.waitForCompletion(); // Desktop will run migrations in the main process - this.encryptService.init(this.configService); const accounts = await firstValueFrom(this.accountService.accounts$); const setUserKeyInMemoryPromises = []; diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.html b/apps/desktop/src/app/tools/send-v2/send-v2.component.html index dad0e541a4d..53dee854012 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.html +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.html @@ -60,7 +60,7 @@ buttonType="primary" (click)="addSendWithoutType()" > - {{ "newSend" | i18n }} + {{ "createSend" | i18n }} diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts index 95c0c971d2c..0df71a78412 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts @@ -79,7 +79,6 @@ export class SendV2Component { protected readonly sendId = signal(null); protected readonly action = signal(Action.None); - private readonly selectedSendTypeOverride = signal(undefined); private sendFormConfigService = inject(DefaultSendFormConfigService); private sendItemsService = inject(SendItemsService); @@ -151,10 +150,9 @@ export class SendV2Component { protected readonly selectedSendType = computed(() => { const action = this.action(); - const typeOverride = this.selectedSendTypeOverride(); - if (action === Action.Add && typeOverride !== undefined) { - return typeOverride; + if (action === Action.Add) { + return undefined; } const sendId = this.sendId(); @@ -173,24 +171,20 @@ export class SendV2Component { } else { this.action.set(Action.Add); this.sendId.set(null); - this.selectedSendTypeOverride.set(type); - const component = this.addEditComponent(); - if (component) { - await component.resetAndLoad(); - } + this.cdr.detectChanges(); + void this.addEditComponent()?.resetAndLoad(); } } - /** Used by old UI to add a send without specifying type (defaults to Text) */ + /** Used by old UI to add a send without specifying type (defaults to File) */ protected async addSendWithoutType(): Promise { - await this.addSend(SendType.Text); + await this.addSend(SendType.File); } protected closeEditPanel(): void { this.action.set(Action.None); this.sendId.set(null); - this.selectedSendTypeOverride.set(undefined); } protected async savedSend(send: SendView): Promise { diff --git a/apps/desktop/src/platform/components/approve-ssh-request.html b/apps/desktop/src/autofill/components/approve-ssh-request.html similarity index 100% rename from apps/desktop/src/platform/components/approve-ssh-request.html rename to apps/desktop/src/autofill/components/approve-ssh-request.html diff --git a/apps/desktop/src/platform/components/approve-ssh-request.ts b/apps/desktop/src/autofill/components/approve-ssh-request.ts similarity index 100% rename from apps/desktop/src/platform/components/approve-ssh-request.ts rename to apps/desktop/src/autofill/components/approve-ssh-request.ts diff --git a/apps/desktop/src/autofill/services/ssh-agent.service.ts b/apps/desktop/src/autofill/services/ssh-agent.service.ts index 7e289720ec8..e3280f07ede 100644 --- a/apps/desktop/src/autofill/services/ssh-agent.service.ts +++ b/apps/desktop/src/autofill/services/ssh-agent.service.ts @@ -32,8 +32,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CipherType } from "@bitwarden/common/vault/enums"; import { DialogService, ToastService } from "@bitwarden/components"; -import { ApproveSshRequestComponent } from "../../platform/components/approve-ssh-request"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; +import { ApproveSshRequestComponent } from "../components/approve-ssh-request"; import { SshAgentPromptType } from "../models/ssh-agent-setting"; @Injectable({ diff --git a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts index dd21cf883f3..b01e62d2af3 100644 --- a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts +++ b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts @@ -177,6 +177,9 @@ describe("DesktopLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -197,6 +200,9 @@ describe("DesktopLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -218,6 +224,9 @@ describe("DesktopLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledLocally, }, + prf: { + enabled: false, + }, }, ], [ @@ -238,6 +247,9 @@ describe("DesktopLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.HardwareUnavailable, }, + prf: { + enabled: false, + }, }, ], [ @@ -258,6 +270,9 @@ describe("DesktopLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported, }, + prf: { + enabled: false, + }, }, ], ]; diff --git a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts index fc57a3873ef..0b1896f02f9 100644 --- a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts +++ b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts @@ -69,6 +69,9 @@ export class DesktopLockComponentService implements LockComponentService { enabled: biometricsStatus == BiometricsStatus.Available, biometricsStatus: biometricsStatus, }, + prf: { + enabled: false, + }, }; return unlockOpts; diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index 5e2a0f41507..61b5da472f1 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Permanent geskrapte item" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Teruggestelde item" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index 7ff2f54321c..302f53eea43 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "تم حذف العنصر بشكل دائم" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "العنصر المستعاد" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index 46958d5ae3d..d9709cf308f 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -101,23 +101,23 @@ } }, "deletionDateDescV2": { - "message": "The Send will be permanently deleted on this date.", + "message": "Send, bu tarixdə həmişəlik silinəcək.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "fileToShare": { - "message": "File to share" + "message": "Paylaşılacaq fayl" }, "hideTextByDefault": { - "message": "Hide text by default" + "message": "Mətni ilkin olaraq gizlət" }, "hideYourEmail": { - "message": "Hide your email address from viewers." + "message": "E-poçt ünvanınız baxanlardan gizlədilsin." }, "limitSendViews": { - "message": "Limit views" + "message": "Baxışları limitlə" }, "limitSendViewsCount": { - "message": "$ACCESSCOUNT$ views left", + "message": "$ACCESSCOUNT$ baxış qaldı", "description": "Displayed under the limit views field on Send", "placeholders": { "accessCount": { @@ -127,41 +127,41 @@ } }, "limitSendViewsHint": { - "message": "No one can view this Send after the limit is reached.", + "message": "Limitə çatdıqdan sonra bu Send-ə heç kim baxa bilməz.", "description": "Displayed under the limit views field on Send" }, "privateNote": { - "message": "Private note" + "message": "Özəl not" }, "sendDetails": { - "message": "Send details", + "message": "Send detalları", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", + "message": "Alıcıların bu \"Send\"ə erişməsi üçün ixtiyari bir parol əlavə edin.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeTextToShare": { - "message": "Text to share" + "message": "Paylaşılacaq mətn" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Yeni Send mətni", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Yeni Send faylı", "description": "Header for new file send" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Send mətninə düzəliş et", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Send faylına düzəliş et", "description": "Header for edit file send" }, "deleteSendPermanentConfirmation": { - "message": "Are you sure you want to permanently delete this Send?", + "message": "Bu Send-i həmişəlik silmək istədiyinizə əminsiniz?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "new": { @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Element birdəfəlik silindi" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Element bərpa edildi" }, @@ -4076,10 +4079,10 @@ "message": "Bu giriş risk altındadır və bir veb sayt əskikdir. Daha güclü təhlükəsizlik üçün bir veb sayt əlavə edin və parolu dəyişdirin." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Zəifliyi olan parol." }, "changeNow": { - "message": "Change now" + "message": "İndi dəyişdir" }, "missingWebsite": { "message": "Əskik veb sayt" @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Elementi arxivlə" }, - "archiveItemConfirmDesc": { - "message": "Arxivlənmiş elementlər ümumi axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək. Bu elementi arxivləmək istədiyinizə əminsiniz?" + "archiveItemDialogContent": { + "message": "Arxivləndikdən sonra, bu element axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək." }, "unArchiveAndSave": { "message": "Arxivdən çıxart və saxla" diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index eefbfc46874..d4d304ab257 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Элемент выдалены назаўсёды" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Элемент адноўлены" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index 7c772655b31..692a98d249f 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Записът е изтрит окончателно" }, + "archivedItemRestored": { + "message": "Архивираният елемент е възстановен" + }, "restoredItem": { "message": "Записът е възстановен" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Архивиране на елемента" }, - "archiveItemConfirmDesc": { - "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" + "archiveItemDialogContent": { + "message": "След като бъде архивиран, този елемент няма да се показва в резултатите при търсене, нито в предложенията за автоматично попълване." }, "unArchiveAndSave": { "message": "Разархивиране и запазване" diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index 6219f374ae1..97e67f9541f 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "বস্তুটি স্থায়ীভাবে মুছে ফেলা হয়েছে" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "বস্তু পুনরুদ্ধারকৃত" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index 622476d1836..49bb2b6559a 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index 864dce62be8..79bdbb7c881 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Element suprimit definitivament" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Element restaurat" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index cb89a325ab8..33469dc30dc 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Položka byla trvale smazána" }, + "archivedItemRestored": { + "message": "Archivovaná položka byla obnovena" + }, "restoredItem": { "message": "Položka byla obnovena" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archivovat položku" }, - "archiveItemConfirmDesc": { - "message": "Archivované položky jsou vyloučeny z obecných výsledků vyhledávání a z návrhů automatického vyplňování. Jste si jisti, že chcete tuto položku archivovat?" + "archiveItemDialogContent": { + "message": "Jakmile bude tato položka archivována, bude vyloučena z výsledků vyhledávání a z návrhů automatického vyplňování." }, "unArchiveAndSave": { "message": "Odebrat z archivu a uložit" diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index 512426c218d..f67a3dbe406 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index d523859005e..2e8c4b72911 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Emne slettet permanent" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Emne gendannet" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 60480b2540f..1d15a27d505 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Eintrag dauerhaft gelöscht" }, + "archivedItemRestored": { + "message": "Archivierter Eintrag wiederhergestellt" + }, "restoredItem": { "message": "Eintrag wiederhergestellt" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Eintrag archivieren" }, - "archiveItemConfirmDesc": { - "message": "Archivierte Einträge werden von allgemeinen Suchergebnissen und Auto-Ausfüllen-Vorschlägen ausgeschlossen. Bist du sicher, dass du diesen Eintrag archivieren möchtest?" + "archiveItemDialogContent": { + "message": "Nach der Archivierung wird dieser Eintrag aus den Suchergebnissen und Auto-Ausfüllen-Vorschlägen ausgeschlossen." }, "unArchiveAndSave": { "message": "Nicht mehr archivieren und speichern" diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index ed7e21d88b8..c5e1a0d98dc 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Το αντικείμενο διαγράφηκε οριστικά" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Το αντικείμενο επαναφέρθηκε" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/en_GB/messages.json b/apps/desktop/src/locales/en_GB/messages.json index b74fb9de282..7b46a4d928e 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index 5d4d48b49c0..42428ea649c 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Permanently deleted item" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Restored item" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index 94be4656381..d4a9938e7b1 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "La ero poreterne forviŝiĝis" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "La ero restariĝis" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index 8426b5fe51b..7620ed56bc1 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Elemento eliminado de forma permanente" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Elemento restaurado" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index 6410076e603..f82a0c90f5d 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Kirje on jäädavalt kustutatud" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Kirje on taastatud" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index c0665dd472d..91c0b13c266 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Elementua betirako ezabatua" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Elementua berreskuratua" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index b0c4940ef0f..3d800cc4417 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "مورد برای همیشه حذف شد" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "مورد بازیابی شد" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "بایگانی آیتم" }, - "archiveItemConfirmDesc": { - "message": "آیتم‌های بایگانی‌شده از نتایج جستجوی عمومی و پیشنهاد ها پر کردن خودکار حذف می‌شوند. آیا مطمئن هستید که می‌خواهید این آیتم را بایگانی کنید؟" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index ac67919ba51..13bf67e8987 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Kohde poistettiin pysyvästi" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Kohde palautettiin" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index e62551edb98..4898f63f9ff 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanenteng tinanggal" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item na nai-restore" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index 424fb9cc124..2aa6ffc959e 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Élément supprimé définitivement" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Élément restauré" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archiver l'élément" }, - "archiveItemConfirmDesc": { - "message": "Les éléments archivés sont exclus des résultats de recherche généraux et des suggestions de remplissage automatique. Êtes-vous sûr de vouloir archiver cet élément ?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index 33a721bf8b0..c4a52a24d6f 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index 876673d3aab..26de7e9688a 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "הפריט נמחק לצמיתות" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "הפריט שוחזר" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "העבר פריט לארכיון" }, - "archiveItemConfirmDesc": { - "message": "פריטים בארכיון מוחרגים מתוצאות חיפוש כללי והצעות למילוי אוטומטי. האם אתה בטוח שברצונך להעביר פריט זה לארכיון?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index ddb6793f64f..30390b1e445 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index fdb0a57dc1b..5331eae2d14 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -101,23 +101,23 @@ } }, "deletionDateDescV2": { - "message": "The Send will be permanently deleted on this date.", + "message": "Send će na ovaj datum biti trajno izbrisan.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "fileToShare": { - "message": "File to share" + "message": "Datoteka za dijeljenje" }, "hideTextByDefault": { - "message": "Hide text by default" + "message": "Zadano sakrij tekst" }, "hideYourEmail": { - "message": "Hide your email address from viewers." + "message": "Sakriti adresu e-pošte od primatelja." }, "limitSendViews": { - "message": "Limit views" + "message": "Ograniči broj pogleda" }, "limitSendViewsCount": { - "message": "$ACCESSCOUNT$ views left", + "message": "Preostalo pogleda: $ACCESSCOUNT$", "description": "Displayed under the limit views field on Send", "placeholders": { "accessCount": { @@ -127,45 +127,45 @@ } }, "limitSendViewsHint": { - "message": "No one can view this Send after the limit is reached.", + "message": "Nakon dosegnutog broja, nitko neće moći pogledati Send.", "description": "Displayed under the limit views field on Send" }, "privateNote": { - "message": "Private note" + "message": "Privatna bilješka" }, "sendDetails": { - "message": "Send details", + "message": "Detalji Senda", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", + "message": "Dodaj neobaveznu lozinku za pristup ovom Sendu.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeTextToShare": { - "message": "Text to share" + "message": "Tekst za dijeljenje" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Novi teksutalni Send", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Novi datotečni Send", "description": "Header for new file send" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Uredi tekstualni Send", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Uredi datotečni Send", "description": "Header for edit file send" }, "deleteSendPermanentConfirmation": { - "message": "Are you sure you want to permanently delete this Send?", + "message": "Sigurno želiš trajno izbrisati ovaj Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "new": { - "message": "New", + "message": "Novo", "description": "for adding new items" }, "newUri": { @@ -786,7 +786,7 @@ "message": "This file is using an outdated encryption method." }, "attachmentUpdated": { - "message": "Attachment updated" + "message": "Privitak ažuriran" }, "maxFileSizeSansPunctuation": { "message": "Najveća veličina datoteke je 500 MB" @@ -989,7 +989,7 @@ "message": "Došlo je do neočekivane pogreške." }, "unexpectedErrorShort": { - "message": "Unexpected error" + "message": "Neočekivana greška" }, "closeThisBitwardenWindow": { "message": "Close this Bitwarden window and try again." @@ -1180,22 +1180,22 @@ "message": "Saznaj više" }, "migrationsFailed": { - "message": "An error occurred updating the encryption settings." + "message": "Dogodila se greška pri ažuriranju postavki šifriranja." }, "updateEncryptionSettingsTitle": { - "message": "Update your encryption settings" + "message": "Ažuriraj svoje postakve šifriranja" }, "updateEncryptionSettingsDesc": { - "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + "message": "Nove preporučene postavke šifriranja poboljšat će sigurnost tvojeg računa. Za ažuriranje, unesi svoju glavnu lozinku." }, "confirmIdentityToContinue": { - "message": "Confirm your identity to continue" + "message": "Za nastavak, potvrdi svoj identitet" }, "enterYourMasterPassword": { - "message": "Enter your master password" + "message": "Unesi svoju glavnu lozinku" }, "updateSettings": { - "message": "Update settings" + "message": "Ažuriraj postavke" }, "featureUnavailable": { "message": "Značajka nije dostupna" @@ -1267,7 +1267,7 @@ "message": "Prati nas" }, "syncNow": { - "message": "Sync now" + "message": "Sinkroniziraj" }, "changeMasterPass": { "message": "Promjeni glavnu lozinku" @@ -1595,7 +1595,7 @@ "message": "1 GB šifriranog prostora za pohranu podataka." }, "premiumSignUpStorageV2": { - "message": "$SIZE$ encrypted storage for file attachments.", + "message": "$SIZE$ šifriranog prostora za privitke.", "placeholders": { "size": { "content": "$1", @@ -1844,19 +1844,19 @@ "message": "Izvezi iz" }, "exportNoun": { - "message": "Export", + "message": "Izvoz", "description": "The noun form of the word Export" }, "exportVerb": { - "message": "Export", + "message": "Izvoz", "description": "The verb form of the word Export" }, "importNoun": { - "message": "Import", + "message": "Uvoz", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Uvoz", "description": "The verb form of the word Import" }, "fileFormat": { @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Stavka trajno izbrisana" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Stavka vraćena" }, @@ -2480,7 +2483,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLink": { - "message": "Copy Send link", + "message": "Kopiraj vezu na Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLinkToClipboard": { @@ -3983,22 +3986,22 @@ "message": "Datoteka spremljena na uređaj. Upravljaj u preuzimanjima svog uređaja." }, "importantNotice": { - "message": "Important notice" + "message": "Važna napomena" }, "setupTwoStepLogin": { - "message": "Set up two-step login" + "message": "Postavi dvostruku autentifikaciju" }, "newDeviceVerificationNoticeContentPage1": { - "message": "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025." + "message": "Bitwarden će, počevši od veljače 2025., za provjeru prijava s novih uređaja poslati kôd na e-poštu tvog računa." }, "newDeviceVerificationNoticeContentPage2": { - "message": "You can set up two-step login as an alternative way to protect your account or change your email to one you can access." + "message": "Prijavu dvostrukom autentifikacijom možeš postaviti kao alternativni način zaštite svog računa ili promijeni svoju e-poštu u onu kojoj možeš pristupiti." }, "remindMeLater": { - "message": "Remind me later" + "message": "Podsjeti me kasnije" }, "newDeviceVerificationNoticePageOneFormContent": { - "message": "Do you have reliable access to your email, $EMAIL$?", + "message": "Imaš li pouzdan pristup svojoj e-pošti: $EMAIL$?", "placeholders": { "email": { "content": "$1", @@ -4007,49 +4010,49 @@ } }, "newDeviceVerificationNoticePageOneEmailAccessNo": { - "message": "No, I do not" + "message": "Ne, nemam" }, "newDeviceVerificationNoticePageOneEmailAccessYes": { - "message": "Yes, I can reliably access my email" + "message": "Da, pouzdano mogu pristupiti svojoj e-pošti" }, "turnOnTwoStepLogin": { - "message": "Turn on two-step login" + "message": "Uključi prijavu dvostrukom autentifikacijom" }, "changeAcctEmail": { - "message": "Change account email" + "message": "Promjeni e-poštu računa" }, "passkeyLogin": { - "message": "Log in with passkey?" + "message": "Prijava pristupnim ključem?" }, "savePasskeyQuestion": { - "message": "Save passkey?" + "message": "Spremi pristupni ključ?" }, "saveNewPasskey": { - "message": "Save as new login" + "message": "Spremi novu prijavu" }, "savePasskeyNewLogin": { - "message": "Save passkey as new login" + "message": "Spremi pristupni ključ kao novu prijavu" }, "noMatchingLoginsForSite": { - "message": "No matching logins for this site" + "message": "Nema prijava za ovu web stranicu" }, "overwritePasskey": { - "message": "Overwrite passkey?" + "message": "Prebriši pristupni ključ?" }, "unableToSavePasskey": { "message": "Unable to save passkey" }, "alreadyContainsPasskey": { - "message": "This item already contains a passkey. Are you sure you want to overwrite the current passkey?" + "message": "Ova stavka već sadrži pristupni ključ. Sigurno želiš prebrisati trenutni pristupni ključ?" }, "passkeyAlreadyExists": { - "message": "A passkey already exists for this application." + "message": "Za ovu aplikaciju već postoji pristupni ključ." }, "applicationDoesNotSupportDuplicates": { "message": "This application does not support duplicates." }, "closeThisWindow": { - "message": "Close this window" + "message": "Zatvori ovaj prozor" }, "allowScreenshots": { "message": "Dozvoli snimanje zaslona" @@ -4076,10 +4079,10 @@ "message": "Ova prijava je ugrožena i nedostaje joj web-stranica. Dodaj web-stranicu i promijeni lozinku za veću sigurnost." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Ranjiva lozinka." }, "changeNow": { - "message": "Change now" + "message": "Promijeni sada" }, "missingWebsite": { "message": "Nedostaje web-stranica" @@ -4110,14 +4113,14 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsTitleNoSearchResults": { - "message": "No search results returned" + "message": "Nema rezultata pretrage" }, "sendsBodyNoItems": { "message": "Sigurno dijeli datoteke i podatke s bilo kime, na bilo kojoj platformi. Tvoji podaci ostaju kriptirani uz ograničenje izloženosti.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoSearchResults": { - "message": "Clear filters or try another search term" + "message": "Očisti filtre ili pokušaj s drugačijom pretragom" }, "generatorNudgeTitle": { "message": "Brzo stvori lozinke" @@ -4338,7 +4341,7 @@ "message": "Vrsta prečaca" }, "editAutotypeKeyboardModifiersDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." + "message": "Uključi jedan ili dva modifikatora: Ctrl, Alt, Win i slovo." }, "invalidShortcut": { "message": "Nevažeći prečac" @@ -4377,7 +4380,7 @@ "message": "Poništi arhiviranje" }, "archived": { - "message": "Archived" + "message": "Arhivirano" }, "itemsInArchive": { "message": "Stavke u arhivi" @@ -4397,23 +4400,23 @@ "archiveItem": { "message": "Arhiviraj stavku" }, - "archiveItemConfirmDesc": { - "message": "Arhivirane stavke biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune. Sigurno želiš arhivirati?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" }, "restartPremium": { - "message": "Restart Premium" + "message": "Ponovno Pokreni Premium" }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Toja Premium pretplata je završila" }, "premiumSubscriptionEndedDesc": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + "message": "Za ponovni pristup svojoj arhivi, ponovno pokreni Premium pretplatu. Ako urediš detalje arhivirane stavke prije ponovnog pokretanja, ona će biti vraćena u tvoj trezor." }, "itemRestored": { - "message": "Item has been restored" + "message": "Stavka je vraćena" }, "zipPostalCodeLabel": { "message": "Poštanski broj" @@ -4455,7 +4458,7 @@ "message": "Do not continue" }, "domain": { - "message": "Domain" + "message": "Domena" }, "keyConnectorDomainTooltip": { "message": "This domain will store your account encryption keys, so make sure you trust it. If you're not sure, check with your admin." @@ -4467,7 +4470,7 @@ "message": "Organization verified" }, "domainVerified": { - "message": "Domain verified" + "message": "Domena potvrđena" }, "leaveOrganizationContent": { "message": "If you don't verify your organization, your access to the organization will be revoked." @@ -4494,7 +4497,7 @@ "message": "Resize side navigation" }, "sessionTimeoutSettingsManagedByOrganization": { - "message": "This setting is managed by your organization." + "message": "Ovom postavkom upravlja tvoja organizacija." }, "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": { "message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", @@ -4529,16 +4532,16 @@ } }, "sessionTimeoutOnRestart": { - "message": "On restart" + "message": "Kod ponovnog pokretanja" }, "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { - "message": "Set an unlock method to change your timeout action" + "message": "Postavi metodu otključavanja za promjenu radnje nakon isteka vremenskog ograničenja trezora" }, "upgrade": { - "message": "Upgrade" + "message": "Nadogradnja" }, "leaveConfirmationDialogTitle": { - "message": "Are you sure you want to leave?" + "message": "Sigurno želiš napustiti?" }, "leaveConfirmationDialogContentOne": { "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features." @@ -4577,12 +4580,12 @@ } }, "acceptTransfer": { - "message": "Accept transfer" + "message": "Prihvati prijenos" }, "declineAndLeave": { "message": "Decline and leave" }, "whyAmISeeingThis": { - "message": "Why am I seeing this?" + "message": "Zašto ovo vidim?" } } diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index 659360ec4a5..49a034cb6d5 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Elem végleges törlése" }, + "archivedItemRestored": { + "message": "Az archivált elem visszaállításra került." + }, "restoredItem": { "message": "Visszaállított elem" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Elem archiválása" }, - "archiveItemConfirmDesc": { - "message": "Az archivált elemek ki vannak zárva az általános keresési eredményekből és az automatikus kitöltési javaslatokból. Biztosan archiválni szeretnénk ezt az elemet?" + "archiveItemDialogContent": { + "message": "Az archiválás után ez az elem kizárásra kerül a keresési eredményekből és az automatikus kitöltési javaslatokból." }, "unArchiveAndSave": { "message": "Archiválás visszavonása és mentés" diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index ed02ed102bd..fe02d601974 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item Terhapus Permanen" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item Yang Dipulihkan" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index a328412149d..b7e225de77e 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -101,23 +101,23 @@ } }, "deletionDateDescV2": { - "message": "The Send will be permanently deleted on this date.", + "message": "Il Send sarà eliminato definitivamente in questa data.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "fileToShare": { - "message": "File to share" + "message": "File da condividere" }, "hideTextByDefault": { - "message": "Hide text by default" + "message": "Nascondi il testo per impostazione predefinita" }, "hideYourEmail": { - "message": "Hide your email address from viewers." + "message": "Nascondi il tuo indirizzo email ai visualizzatori." }, "limitSendViews": { - "message": "Limit views" + "message": "Limita le visualizzazioni" }, "limitSendViewsCount": { - "message": "$ACCESSCOUNT$ views left", + "message": "$ACCESSCOUNT$ visualizzazioni rimaste", "description": "Displayed under the limit views field on Send", "placeholders": { "accessCount": { @@ -127,41 +127,41 @@ } }, "limitSendViewsHint": { - "message": "No one can view this Send after the limit is reached.", + "message": "Nessuno potrà visualizzare il Send dopo il raggiungimento del limite.", "description": "Displayed under the limit views field on Send" }, "privateNote": { - "message": "Private note" + "message": "Nota privata" }, "sendDetails": { - "message": "Send details", + "message": "Dettagli del Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", + "message": "Richiedi ai destinatari una password per aprire questo Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeTextToShare": { - "message": "Text to share" + "message": "Testo da condividere" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Nuovo Send (testo)", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Nuovo Send (file)", "description": "Header for new file send" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Modifica Send testuale", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Modifica Send con file", "description": "Header for edit file send" }, "deleteSendPermanentConfirmation": { - "message": "Are you sure you want to permanently delete this Send?", + "message": "Vuoi davvero eliminare questo Send? L'azione è irreversibile.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "new": { @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Elemento eliminato definitivamente" }, + "archivedItemRestored": { + "message": "Elemento estratto dall'archivio" + }, "restoredItem": { "message": "Elemento ripristinato" }, @@ -4076,10 +4079,10 @@ "message": "Questo login è a rischio e non contiene un sito web. Aggiungi un sito web e cambia la password per maggiore sicurezza." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Password vulnerabile." }, "changeNow": { - "message": "Change now" + "message": "Cambiala subito" }, "missingWebsite": { "message": "Sito Web mancante" @@ -4374,7 +4377,7 @@ "description": "Verb" }, "unArchive": { - "message": "Rimuovi dall'Archivio" + "message": "Togli dall'archivio" }, "archived": { "message": "Archiviato" @@ -4386,19 +4389,19 @@ "message": "Nessun elemento nell'archivio" }, "noItemsInArchiveDesc": { - "message": "Gli elementi archiviati appariranno qui e saranno esclusi dai risultati di ricerca e dall'auto-riempimento." + "message": "Gli elementi archiviati appariranno qui e saranno esclusi dai risultati di ricerca e dal riempimento automatico." }, "itemWasSentToArchive": { "message": "Elemento archiviato" }, "itemWasUnarchived": { - "message": "Elemento rimosso dall'archivio" + "message": "Elemento estratto dall'archivio" }, "archiveItem": { "message": "Archivia elemento" }, - "archiveItemConfirmDesc": { - "message": "Gli elementi archiviati sono esclusi dai risultati di ricerca e dall'auto-riempimento. Vuoi davvero archiviare questo elemento?" + "archiveItemDialogContent": { + "message": "Una volta archiviato, questo elemento sarà escluso dai risultati di ricerca e dai suggerimenti del completamento automatico." }, "unArchiveAndSave": { "message": "Togli dall'archivio e salva" diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index 79477e9a8ae..b97a6614325 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "完全に削除されたアイテム" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "リストアされたアイテム" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index 8c0e9cf659c..4923df5e18b 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index 33a721bf8b0..c4a52a24d6f 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index 84aa5e53262..60b38ad181a 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "ಶಾಶ್ವತವಾಗಿ ಅಳಿಸಲಾದ ಐಟಂ" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "ಐಟಂ ಅನ್ನು ಮರುಸ್ಥಾಪಿಸಲಾಗಿದೆ" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index 46da23b2b4a..9bbe324505b 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "영구적으로 삭제된 항목" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "복원된 항목" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index b2b18baf647..0b7f6613f83 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Elementas ištrintas visam laikui" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Elementas atkurtas" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index 11348ebb5f1..8775b608c61 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Vienums neatgriezeniski izdzēsts" }, + "archivedItemRestored": { + "message": "Arhīva vienums atjaunots" + }, "restoredItem": { "message": "Vienums atjaunots" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Arhivēt vienumu" }, - "archiveItemConfirmDesc": { - "message": "Arhivētie vienumi netiek iekļauti vispārējās meklēšanas iznākumos un automātiskās aizpildes ieteikumos. Vai tiešām ahrivēt šo vienumu?" + "archiveItemDialogContent": { + "message": "Pēc ievietošanas arhīvā šis vienums netiks iekļauts meklēšanas iznākumā un automātiskās aizpildes ieteikumos." }, "unArchiveAndSave": { "message": "Atcelt arhivēšanu un saglabāt" diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index 53ef1cd0c62..76cf594b54a 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index c13f70c64d7..f8131201ff9 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "എന്നെന്നേക്കുമായി നീക്കം ചെയ്ത ഇനം" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "വീണ്ടെടുത്ത ഇനങ്ങൾ" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index 33a721bf8b0..c4a52a24d6f 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index bc32f53140a..14d7ff6d701 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index be6a2cdebb5..884aa9ac345 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Slett objektet permanent" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Gjenopprettet objekt" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index 846c4261187..6440aa9db29 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index 9b510783286..56ef39ec97a 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -101,23 +101,23 @@ } }, "deletionDateDescV2": { - "message": "The Send will be permanently deleted on this date.", + "message": "Op deze datum wordt de Send definitief verwijderd.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "fileToShare": { - "message": "File to share" + "message": "Bestand om te delen" }, "hideTextByDefault": { - "message": "Hide text by default" + "message": "Tekst standaard verbergen" }, "hideYourEmail": { - "message": "Hide your email address from viewers." + "message": "Je e-mailadres voor ontvangers verbergen." }, "limitSendViews": { - "message": "Limit views" + "message": "Weergaven limiteren" }, "limitSendViewsCount": { - "message": "$ACCESSCOUNT$ views left", + "message": "$ACCESSCOUNT$ weergaven over", "description": "Displayed under the limit views field on Send", "placeholders": { "accessCount": { @@ -127,41 +127,41 @@ } }, "limitSendViewsHint": { - "message": "No one can view this Send after the limit is reached.", + "message": "Niemand kan deze Send weergeven als de limiet is bereikt.", "description": "Displayed under the limit views field on Send" }, "privateNote": { - "message": "Private note" + "message": "Privénotitie" }, "sendDetails": { - "message": "Send details", + "message": "Send-details", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", + "message": "Voeg een optioneel wachtwoord toe voor ontvangers om toegang te krijgen tot deze Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeTextToShare": { - "message": "Text to share" + "message": "Te delen tekst" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Nieuwe Send (tekst)", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Nieuwe Send (bestand)", "description": "Header for new file send" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Send (tekst) bewerken", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Send (bestand) bewerken", "description": "Header for edit file send" }, "deleteSendPermanentConfirmation": { - "message": "Are you sure you want to permanently delete this Send?", + "message": "Weet je zeker dat je deze Send permanent wil verwijderen?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "new": { @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Definitief verwijderd item" }, + "archivedItemRestored": { + "message": "Gearchiveerd item hersteld" + }, "restoredItem": { "message": "Hersteld item" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Item archiveren" }, - "archiveItemConfirmDesc": { - "message": "Gearchiveerde items worden uitgesloten van algemene zoekresultaten en automatische invulsuggesties. Weet je zeker dat je dit item wilt archiveren?" + "archiveItemDialogContent": { + "message": "Eenmaal gearchiveerd wordt dit item uitgesloten van zoekresultaten en suggesties voor automatisch invullen." }, "unArchiveAndSave": { "message": "Dearchiveren en opslaan" diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index 8d3a9b2197a..d7e50fc314d 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index 5e470bb8125..1751612508c 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index b6d51e0627a..6bd454f5aad 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Element został trwale usunięty" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Element został przywrócony" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archiwizuj element" }, - "archiveItemConfirmDesc": { - "message": "Zarchiwizowane elementy są wykluczone z wyników wyszukiwania i sugestii autouzupełniania. Czy na pewno chcesz archiwizować element?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index 31fe1915faa..e48942b409a 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item apagado para sempre" }, + "archivedItemRestored": { + "message": "Item arquivado restaurado" + }, "restoredItem": { "message": "Item restaurado" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Arquivar item" }, - "archiveItemConfirmDesc": { - "message": "Itens arquivados são excluídos dos resultados gerais de busca e das sugestões de preenchimento automático. Tem certeza de que deseja arquivar este item?" + "archiveItemDialogContent": { + "message": "Ao arquivar, o item será excluído dos resultados de busca e sugestões de preenchimento automático." }, "unArchiveAndSave": { "message": "Desarquivar e salvar" diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index 4f35aa7bbee..2eee6006d30 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -600,7 +600,7 @@ "message": "Eliminar anexo" }, "deleteItemConfirmation": { - "message": "Tem a certeza de que pretende eliminar este item?" + "message": "Pretende realmente mover este item para o lixo?" }, "deletedItem": { "message": "Item movido para o lixo" @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item eliminado permanentemente" }, + "archivedItemRestored": { + "message": "Item arquivado restaurado" + }, "restoredItem": { "message": "Item restaurado" }, @@ -4397,14 +4400,14 @@ "archiveItem": { "message": "Arquivar item" }, - "archiveItemConfirmDesc": { - "message": "Os itens arquivados são excluídos dos resultados gerais da pesquisa e das sugestões de preenchimento automático. Tem a certeza de que pretende arquivar este item?" + "archiveItemDialogContent": { + "message": "Depois de arquivado, este item será excluído dos resultados de pesquisa e das sugestões de preenchimento automático." }, "unArchiveAndSave": { "message": "Desarquivar e guardar" }, "restartPremium": { - "message": "Reiniciar Premium" + "message": "Reiniciar o Premium" }, "premiumSubscriptionEnded": { "message": "A sua subscrição Premium terminou" diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index 05eb279529f..c3f84b77a45 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Articol șters permanent" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Articol restabilit" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index 5c9a0a201fe..71e0d030702 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Элемент удален навсегда" }, + "archivedItemRestored": { + "message": "Архивированный элемент восстановлен" + }, "restoredItem": { "message": "Элемент восстановлен" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Архивировать элемент" }, - "archiveItemConfirmDesc": { - "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" + "archiveItemDialogContent": { + "message": "После архивации этот элемент будет исключен из результатов поиска и предложений по автозаполнению." }, "unArchiveAndSave": { "message": "Разархивировать и сохранить" diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index bcbe7ae5353..3dcfe31c354 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index c0ec24a17d5..9bfb31061b3 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Položka bola natrvalo odstránená" }, + "archivedItemRestored": { + "message": "Archivovaná položka bola obnovená" + }, "restoredItem": { "message": "Položka bola obnovená" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archivovať položku" }, - "archiveItemConfirmDesc": { - "message": "Archivované položky sú vylúčené zo všeobecného vyhľadávania a z návrhov automatického vypĺňania. Naozaj chcete archivovať túto položku?" + "archiveItemDialogContent": { + "message": "Po archivácii bude táto položka vylúčená z výsledkov vyhľadávania a návrhov automatického vypĺňania." }, "unArchiveAndSave": { "message": "Zrušiť archiváciu a uložiť" diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index 54a01e0e77d..fb12bca7dfe 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index b2bc5561459..612ba9bc6ae 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Трајно избрисана ставка" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Ставка враћена" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Архивирај ставку" }, - "archiveItemConfirmDesc": { - "message": "Архивиране ставке су искључене из општих резултата претраге и предлога за ауто попуњавање. Јесте ли сигурни да желите да архивирате ову ставку?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index 91330769874..9bba277c3d0 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -101,7 +101,7 @@ } }, "deletionDateDescV2": { - "message": "The Send will be permanently deleted on this date.", + "message": "Denna Send kommer att raderas permanent på detta datum.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "fileToShare": { @@ -111,7 +111,7 @@ "message": "Dölj text som standard" }, "hideYourEmail": { - "message": "Hide your email address from viewers." + "message": "Dölj din e-postadress från tittarna." }, "limitSendViews": { "message": "Begränsa antalet visningar" @@ -127,41 +127,41 @@ } }, "limitSendViewsHint": { - "message": "No one can view this Send after the limit is reached.", + "message": "Ingen kan se denna Send efter att gränsen har nåtts.", "description": "Displayed under the limit views field on Send" }, "privateNote": { "message": "Privat anteckning" }, "sendDetails": { - "message": "Send details", + "message": "Send-detaljer", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", + "message": "Lägg till ett valfritt lösenord för att mottagarna ska få åtkomst till denna Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeTextToShare": { "message": "Text att dela" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Ny Send för text", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Ny Send för fil", "description": "Header for new file send" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Redigera Send för text", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Redigera Send för fil", "description": "Header for edit file send" }, "deleteSendPermanentConfirmation": { - "message": "Are you sure you want to permanently delete this Send?", + "message": "Är du säker på att du vill ta bort denna Send permanent?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "new": { @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Raderade objekt permanent" }, + "archivedItemRestored": { + "message": "Arkiverat objekt återställt" + }, "restoredItem": { "message": "Återställde objekt" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Arkivera objekt" }, - "archiveItemConfirmDesc": { - "message": "Arkiverade objekt är uteslutna från allmänna sökresultat och förslag för autofyll. Är du säker på att du vill arkivera detta objekt?" + "archiveItemDialogContent": { + "message": "När du har arkiverat kommer detta objekt att uteslutas från sökresultat och förslag till autofyll." }, "unArchiveAndSave": { "message": "Avarkivera och spara" diff --git a/apps/desktop/src/locales/ta/messages.json b/apps/desktop/src/locales/ta/messages.json index 722110dd8ab..60f3a54e8db 100644 --- a/apps/desktop/src/locales/ta/messages.json +++ b/apps/desktop/src/locales/ta/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "உருப்படி நிரந்தரமாக நீக்கப்பட்டது" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "உருப்படி மீட்டெடுக்கப்பட்டது" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index 33a721bf8b0..c4a52a24d6f 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index 23c502ab350..844fe92c955 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemConfirmDesc": { - "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index 1626ebe8cab..0ff95da162e 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Kayıt kalıcı olarak silindi" }, + "archivedItemRestored": { + "message": "Arşivlenmiş kayıt geri getirildi" + }, "restoredItem": { "message": "Kayıt geri yüklendi" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Kaydı arşivle" }, - "archiveItemConfirmDesc": { - "message": "Arşivlenmiş kayıtlar genel arama sonuçları ve otomatik doldurma önerilerinden hariç tutulur. Bu kaydı arşivlemek istediğinizden emin misiniz?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Arşivden çıkar ve kaydet" diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index 65f2bb6f66f..7e068b33d02 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Запис остаточно видалено" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Запис відновлено" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "Архівувати запис" }, - "archiveItemConfirmDesc": { - "message": "Архівовані записи виключаються з результатів звичайного пошуку та пропозицій автозаповнення. Ви дійсно хочете архівувати цей запис?" + "archiveItemDialogContent": { + "message": "Once archived, this item will be excluded from search results and autofill suggestions." }, "unArchiveAndSave": { "message": "Unarchive and save" diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index 7fc635bfae3..fcad01eb5b1 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -101,23 +101,23 @@ } }, "deletionDateDescV2": { - "message": "The Send will be permanently deleted on this date.", + "message": "Send sẽ được xóa vĩnh viễn vào ngày này.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "fileToShare": { - "message": "File to share" + "message": "Tệp để chia sẻ" }, "hideTextByDefault": { - "message": "Hide text by default" + "message": "Ẩn văn bản theo mặc định" }, "hideYourEmail": { - "message": "Hide your email address from viewers." + "message": "Ẩn địa chỉ email của bạn với người xem." }, "limitSendViews": { - "message": "Limit views" + "message": "Giới hạn số lượt xem" }, "limitSendViewsCount": { - "message": "$ACCESSCOUNT$ views left", + "message": "$ACCESSCOUNT$ lượt xem còn lại", "description": "Displayed under the limit views field on Send", "placeholders": { "accessCount": { @@ -127,45 +127,45 @@ } }, "limitSendViewsHint": { - "message": "No one can view this Send after the limit is reached.", + "message": "Không ai có thể xem Send này sau khi đạt đến giới hạn.", "description": "Displayed under the limit views field on Send" }, "privateNote": { - "message": "Private note" + "message": "Ghi chú riêng tư" }, "sendDetails": { - "message": "Send details", + "message": "Chi tiết Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", + "message": "Thêm mật khẩu tùy chọn cho người nhận để có thể truy cập vào Send này.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeTextToShare": { - "message": "Text to share" + "message": "Nội dung chia sẻ" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Send văn bản mới", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Send tập tin mới", "description": "Header for new file send" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Sửa Send văn bản", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Sửa Send tập tin", "description": "Header for edit file send" }, "deleteSendPermanentConfirmation": { - "message": "Are you sure you want to permanently delete this Send?", + "message": "Bạn có chắc chắn muốn xóa vĩnh viễn Send này không?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "new": { - "message": "New", + "message": "Mới", "description": "for adding new items" }, "newUri": { @@ -1267,7 +1267,7 @@ "message": "Theo dõi chúng tôi" }, "syncNow": { - "message": "Sync now" + "message": "Đồng bộ ngay" }, "changeMasterPass": { "message": "Thay đổi mật khẩu chính" @@ -1844,19 +1844,19 @@ "message": "Xuất từ" }, "exportNoun": { - "message": "Export", + "message": "Xuất", "description": "The noun form of the word Export" }, "exportVerb": { - "message": "Export", + "message": "Xuất", "description": "The verb form of the word Export" }, "importNoun": { - "message": "Import", + "message": "Nhập", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Nhập", "description": "The verb form of the word Import" }, "fileFormat": { @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Đã xóa vĩnh viễn mục" }, + "archivedItemRestored": { + "message": "Mục lưu trữ đã được khôi phục" + }, "restoredItem": { "message": "Mục đã được khôi phục" }, @@ -2480,7 +2483,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLink": { - "message": "Copy Send link", + "message": "Sao chép liên kết Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLinkToClipboard": { @@ -4076,10 +4079,10 @@ "message": "Thông tin đăng nhập này có rủi ro và thiếu một trang web. Hãy thêm trang web và đổi mật khẩu để tăng cường bảo mật." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Mật khẩu dễ bị tấn công." }, "changeNow": { - "message": "Change now" + "message": "Thay đổi ngay" }, "missingWebsite": { "message": "Thiếu trang web" @@ -4110,14 +4113,14 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsTitleNoSearchResults": { - "message": "No search results returned" + "message": "Không có kết quả tìm kiếm nào được trả về" }, "sendsBodyNoItems": { "message": "Chia sẻ tệp tin và dữ liệu một cách an toàn với bất kỳ ai, trên bất kỳ nền tảng nào. Thông tin của bạn sẽ được mã hóa đầu cuối để hạn chế rủi ro bị lộ.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoSearchResults": { - "message": "Clear filters or try another search term" + "message": "Xóa bộ lọc hoặc thử cụm từ tìm kiếm khác" }, "generatorNudgeTitle": { "message": "Tạo mật khẩu nhanh chóng" @@ -4338,7 +4341,7 @@ "message": "Phím tắt nhập liệu" }, "editAutotypeKeyboardModifiersDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." + "message": "Bao gồm một hoặc hai phím bổ trợ sau: Ctrl, Alt, Win và một chữ cái." }, "invalidShortcut": { "message": "Phím tắt không hợp lệ" @@ -4377,7 +4380,7 @@ "message": "Hủy lưu trữ" }, "archived": { - "message": "Archived" + "message": "Đã lưu trữ" }, "itemsInArchive": { "message": "Các mục trong kho lưu trữ" @@ -4397,23 +4400,23 @@ "archiveItem": { "message": "Lưu trữ mục" }, - "archiveItemConfirmDesc": { - "message": "Các mục đã lưu trữ sẽ bị loại khỏi kết quả tìm kiếm chung và gợi ý tự động điền. Bạn có chắc chắn muốn lưu trữ mục này không?" + "archiveItemDialogContent": { + "message": "Khi đã lưu trữ, mục này sẽ bị loại khỏi kết quả tìm kiếm và gợi ý tự động điền." }, "unArchiveAndSave": { - "message": "Unarchive and save" + "message": "Bỏ lưu trữ và lưu" }, "restartPremium": { - "message": "Restart Premium" + "message": "Khởi động lại gói Cao cấp" }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Gói đăng ký Cao cấp của bạn đã kết thúc" }, "premiumSubscriptionEndedDesc": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + "message": "Để lấy lại quyền truy cập vào lưu trữ của bạn, hãy khởi động lại gói đăng ký Cao cấp. Nếu bạn chỉnh sửa chi tiết cho một mục đã lưu trữ trước khi khởi động lại, mục đó sẽ được chuyển trở lại kho của bạn." }, "itemRestored": { - "message": "Item has been restored" + "message": "Mục đã được khôi phục" }, "zipPostalCodeLabel": { "message": "Mã ZIP / Bưu điện" @@ -4446,43 +4449,43 @@ "message": "Nâng cấp lên gói Cao cấp" }, "removeMasterPasswordForOrgUserKeyConnector": { - "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." + "message": "Tổ chức của bạn không còn sử dụng mật khẩu chính để đăng nhập vào Bitwarden. Để tiếp tục, hãy xác minh tổ chức và tên miền." }, "continueWithLogIn": { - "message": "Continue with log in" + "message": "Tiếp tục đăng nhập" }, "doNotContinue": { - "message": "Do not continue" + "message": "Không tiếp tục" }, "domain": { - "message": "Domain" + "message": "Tên miền" }, "keyConnectorDomainTooltip": { - "message": "This domain will store your account encryption keys, so make sure you trust it. If you're not sure, check with your admin." + "message": "Tên miền này sẽ lưu trữ các khóa mã hóa tài khoản của bạn, vì vậy hãy đảm bảo bạn tin tưởng nó. Nếu bạn không chắc chắn, hãy kiểm tra với quản trị viên của bạn." }, "verifyYourOrganization": { - "message": "Verify your organization to log in" + "message": "Xác minh tổ chức của bạn để đăng nhập" }, "organizationVerified": { - "message": "Organization verified" + "message": "Tổ chức đã được xác minh" }, "domainVerified": { - "message": "Domain verified" + "message": "Tên miền đã được xác minh" }, "leaveOrganizationContent": { - "message": "If you don't verify your organization, your access to the organization will be revoked." + "message": "Nếu bạn không xác minh tổ chức của mình, quyền truy cập vào tổ chức sẽ bị thu hồi." }, "leaveNow": { - "message": "Leave now" + "message": "Rời khỏi ngay" }, "verifyYourDomainToLogin": { - "message": "Verify your domain to log in" + "message": "Xác minh tên miền của bạn để đăng nhập" }, "verifyYourDomainDescription": { - "message": "To continue with log in, verify this domain." + "message": "Để tiếp tục đăng nhập, hãy xác minh tên miền này." }, "confirmKeyConnectorOrganizationUserDescription": { - "message": "To continue with log in, verify the organization and domain." + "message": "Để tiếp tục đăng nhập, hãy xác minh tổ chức và tên miền." }, "sessionTimeoutSettingsAction": { "message": "Hành động sau khi đóng kho" @@ -4491,7 +4494,7 @@ "message": "Thời gian hết phiên" }, "resizeSideNavigation": { - "message": "Resize side navigation" + "message": "Thay đổi kích thước thanh bên" }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Cài đặt này do tổ chức của bạn quản lý." diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 3dd57eac4da..1e7f860a65f 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -355,7 +355,7 @@ "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { - "message": "以避免额外的数据丢失。", + "message": "以避免进一步的数据丢失。", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "january": { @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "项目已永久删除" }, + "archivedItemRestored": { + "message": "归档项目已恢复" + }, "restoredItem": { "message": "项目已恢复" }, @@ -2565,10 +2568,10 @@ "message": "更新主密码" }, "updateMasterPasswordWarning": { - "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "updateWeakMasterPasswordWarning": { - "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "changePasswordWarning": { "message": "更改密码后,您需要使用新密码登录。在其他设备上的活动会话将在一小时内注销。" @@ -3180,7 +3183,7 @@ "message": "请求获得批准后,您将收到通知" }, "needAnotherOption": { - "message": "必须在 Bitwarden App 的设置中启用设备登录。需要其他选项吗?" + "message": "必须在 Bitwarden App 的设置中设置设备登录。需要其他选项吗?" }, "viewAllLogInOptions": { "message": "查看所有登录选项" @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "归档项目" }, - "archiveItemConfirmDesc": { - "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" + "archiveItemDialogContent": { + "message": "归档后,此项目将被排除在一般搜索结果和自动填充建议之外。" }, "unArchiveAndSave": { "message": "取消归档并保存" diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index 43796c5b6af..6ca01de8d71 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -309,10 +309,10 @@ "message": "啟用 SSH 代理程式" }, "enableSshAgentDesc": { - "message": "啟用SSBitwardenH代理以從 Bitwarden 密碼庫簽發SSH請求" + "message": "啟用 SSH 代理以從 Bitwarden 密碼庫直接簽署 SSH 請求。" }, "enableSshAgentHelp": { - "message": "SSH代理是一個針對開發者的服務,它能夠直接從 Bitwarden 密碼庫簽發SSH請求。" + "message": "SSH 代理是一個針對開發者的服務,它能夠直接從 Bitwarden 密碼庫簽署 SSH 請求。" }, "sshAgentPromptBehavior": { "message": "使用 SSH 代理程式時要求授權" @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "項目已永久刪除" }, + "archivedItemRestored": { + "message": "已還原封存項目" + }, "restoredItem": { "message": "項目已還原" }, @@ -4397,8 +4400,8 @@ "archiveItem": { "message": "封存項目" }, - "archiveItemConfirmDesc": { - "message": "封存的項目將不會出現在一般搜尋結果或自動填入建議中。確定要封存此項目嗎?" + "archiveItemDialogContent": { + "message": "封存後,此項目將不會顯示在搜尋結果與自動填入建議中。" }, "unArchiveAndSave": { "message": "取消封存並儲存" diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 9d8eae15791..08cbdb913e6 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.12.1", + "version": "2026.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.12.1", + "version": "2026.1.0", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 2ac5d339a95..859a18fefd0 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": "2025.12.1", + "version": "2026.1.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.html index e4e11b82a8a..daf84c642d6 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.html +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.html @@ -17,16 +17,16 @@ [text]="organization.node.name" [appA11yTitle]="organization.node.name" (click)="applyFilter($event, organization)" - /> - @if (!organization.node.enabled) { - + > + @if (!organization.node.enabled) { - - } + } + } } diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.html index e0ae4687ed8..2110c545d9e 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.html +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.html @@ -6,11 +6,11 @@ - + @if (showCollectionsFilter()) { - @for (collection of (collections$ | async)?.children ?? []; track collection.node.id) { + @for (collection of collections()?.children ?? []; track collection.node.id) { } @@ -32,7 +32,7 @@ [appA11yTitle]="'folders' | i18n" [disableToggleOnClick]="true" > - @for (folder of (folders$ | async)?.children ?? []; track folder.node.id) { + @for (folder of folders()?.children ?? []; track folder.node.id) { >; - protected collections$: Observable>; - protected folders$: Observable>; - protected cipherTypes$: Observable>; + protected readonly organizations = toSignal(this.vaultFilterService.organizationTree$); + protected readonly collections = toSignal(this.vaultFilterService.collectionTree$); + protected readonly folders = toSignal(this.vaultFilterService.folderTree$); + protected readonly cipherTypes = toSignal(this.vaultFilterService.cipherTypeTree$); protected readonly showCollectionsFilter = computed(() => { - return this.organizations$ != null && !this.activeFilter()?.isMyVaultSelected; + return ( + this.organizations() != null && + !this.activeFilter()?.isMyVaultSelected && + !this.allOrganizationsDisabled() + ); + }); + + protected readonly allOrganizationsDisabled = computed(() => { + if (!this.organizations()) { + return false; + } + const orgs = this.organizations().children.filter((org) => org.node.id !== "MyVault"); + return orgs.length > 0 && orgs.every((org) => !org.node.enabled); }); private async setActivePolicies() { @@ -98,16 +107,9 @@ export class VaultFilterComponent implements OnInit { async ngOnInit(): Promise { this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - this.organizations$ = this.vaultFilterService.organizationTree$; - if ( - this.organizations$ != null && - (await firstValueFrom(this.organizations$)).children.length > 0 - ) { + if (this.organizations() != null && this.organizations().children.length > 0) { await this.setActivePolicies(); } - this.cipherTypes$ = this.vaultFilterService.cipherTypeTree$; - this.folders$ = this.vaultFilterService.folderTree$; - this.collections$ = this.vaultFilterService.collectionTree$; this.showArchiveVaultFilter = await firstValueFrom( this.cipherArchiveService.hasArchiveFlagEnabled$, diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts index c104f76ff2d..9d5fad2fe4c 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -79,6 +79,8 @@ import { VaultFilter, VaultFilterServiceAbstraction as VaultFilterService, RoutedVaultFilterBridgeService, + VaultItemsTransferService, + DefaultVaultItemsTransferService, } from "@bitwarden/vault"; import { SearchBarService } from "../../../app/layout/search/search-bar.service"; @@ -130,6 +132,7 @@ const BroadcasterSubscriptionId = "VaultComponent"; provide: COPY_CLICK_LISTENER, useExisting: VaultComponent, }, + { provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }, ], }) export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { @@ -214,6 +217,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService, private vaultFilterService: VaultFilterService, + private vaultItemTransferService: VaultItemsTransferService, ) {} async ngOnInit() { @@ -266,6 +270,11 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { if (this.vaultItemsComponent) { await this.vaultItemsComponent.refresh().catch(() => {}); } + if (this.activeUserId) { + void this.vaultItemTransferService.enforceOrganizationDataOwnership( + this.activeUserId, + ); + } break; case "modalShown": this.showingModal = true; @@ -372,6 +381,8 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { .subscribe((collections) => { this.filteredCollections = collections; }); + + void this.vaultItemTransferService.enforceOrganizationDataOwnership(this.activeUserId); } ngOnDestroy() { @@ -794,12 +805,15 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { type: CipherViewLikeUtils.getType(cipher), // Normalize undefined organizationId to null for filter compatibility organizationId: cipher.organizationId ?? null, + // Normalize empty string folderId to null for filter compatibility + folderId: cipher.folderId ? cipher.folderId : null, // Explicitly include isDeleted and isArchived since they might be getters isDeleted: CipherViewLikeUtils.isDeleted(cipher), isArchived: CipherViewLikeUtils.isArchived(cipher), }; return filterFn(proxyCipher as any); } + return filterFn(cipher); }; } diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index c1ab9d6f22a..fe2914216a3 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -92,6 +92,8 @@ import { PasswordRepromptService, CipherFormComponent, ArchiveCipherUtilitiesService, + VaultItemsTransferService, + DefaultVaultItemsTransferService, } from "@bitwarden/vault"; import { NavComponent } from "../../../app/layout/nav.component"; @@ -150,6 +152,7 @@ const BroadcasterSubscriptionId = "VaultComponent"; provide: COPY_CLICK_LISTENER, useExisting: VaultV2Component, }, + { provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }, ], }) export class VaultV2Component @@ -264,6 +267,7 @@ export class VaultV2Component private policyService: PolicyService, private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, private masterPasswordService: MasterPasswordServiceAbstraction, + private vaultItemTransferService: VaultItemsTransferService, ) {} async ngOnInit() { @@ -317,6 +321,11 @@ export class VaultV2Component .catch(() => {}); await this.vaultFilterComponent.reloadOrganizations().catch(() => {}); } + if (this.activeUserId) { + void this.vaultItemTransferService.enforceOrganizationDataOwnership( + this.activeUserId, + ); + } break; case "modalShown": this.showingModal = true; @@ -420,6 +429,8 @@ export class VaultV2Component .subscribe((collections) => { this.allCollections = collections; }); + + void this.vaultItemTransferService.enforceOrganizationDataOwnership(this.activeUserId); } ngOnDestroy() { @@ -631,77 +642,80 @@ export class VaultV2Component }); } - switch (cipher.type) { - case CipherType.Login: - if ( - cipher.login.canLaunch || - cipher.login.username != null || - cipher.login.password != null - ) { - menu.push({ type: "separator" }); - } - if (cipher.login.canLaunch) { - menu.push({ - label: this.i18nService.t("launch"), - click: () => this.platformUtilsService.launchUri(cipher.login.launchUri), - }); - } - if (cipher.login.username != null) { - menu.push({ - label: this.i18nService.t("copyUsername"), - click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"), - }); - } - if (cipher.login.password != null && cipher.viewPassword) { - menu.push({ - label: this.i18nService.t("copyPassword"), - click: () => { - this.copyValue(cipher, cipher.login.password, "password", "Password"); - this.eventCollectionService - .collect(EventType.Cipher_ClientCopiedPassword, cipher.id) - .catch(() => {}); - }, - }); - } - if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) { - menu.push({ - label: this.i18nService.t("copyVerificationCodeTotp"), - click: async () => { - const value = await firstValueFrom( - this.totpService.getCode$(cipher.login.totp), - ).catch((): any => null); - if (value) { - this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); - } - }, - }); - } - break; - case CipherType.Card: - if (cipher.card.number != null || cipher.card.code != null) { - menu.push({ type: "separator" }); - } - if (cipher.card.number != null) { - menu.push({ - label: this.i18nService.t("copyNumber"), - click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"), - }); - } - if (cipher.card.code != null) { - menu.push({ - label: this.i18nService.t("copySecurityCode"), - click: () => { - this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code"); - this.eventCollectionService - .collect(EventType.Cipher_ClientCopiedCardCode, cipher.id) - .catch(() => {}); - }, - }); - } - break; - default: - break; + if (!cipher.isDeleted) { + switch (cipher.type) { + case CipherType.Login: + if ( + cipher.login.canLaunch || + cipher.login.username != null || + cipher.login.password != null + ) { + menu.push({ type: "separator" }); + } + if (cipher.login.canLaunch) { + menu.push({ + label: this.i18nService.t("launch"), + click: () => this.platformUtilsService.launchUri(cipher.login.launchUri), + }); + } + if (cipher.login.username != null) { + menu.push({ + label: this.i18nService.t("copyUsername"), + click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"), + }); + } + if (cipher.login.password != null && cipher.viewPassword) { + menu.push({ + label: this.i18nService.t("copyPassword"), + click: () => { + this.copyValue(cipher, cipher.login.password, "password", "Password"); + this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedPassword, cipher.id) + .catch(() => {}); + }, + }); + } + if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) { + menu.push({ + label: this.i18nService.t("copyVerificationCodeTotp"), + click: async () => { + const value = await firstValueFrom( + this.totpService.getCode$(cipher.login.totp), + ).catch((): any => null); + if (value) { + this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); + } + }, + }); + } + break; + case CipherType.Card: + if (cipher.card.number != null || cipher.card.code != null) { + menu.push({ type: "separator" }); + } + if (cipher.card.number != null) { + menu.push({ + label: this.i18nService.t("copyNumber"), + click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"), + }); + } + if (cipher.card.code != null) { + menu.push({ + label: this.i18nService.t("copySecurityCode"), + click: () => { + this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code"); + this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedCardCode, cipher.id) + .catch(() => {}); + }, + }); + } + break; + default: + break; + } } + invokeMenu(menu); } diff --git a/apps/web/package.json b/apps/web/package.json index 0e844fbbe79..033c5b000bf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2026.1.0", + "version": "2026.1.1", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 074e9c38f6e..604ba252231 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -25,7 +25,7 @@ (click)="invite(organization)" [disabled]="!firstLoaded()" > - + {{ "inviteMember" | i18n }} } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.html index 63a59208cc0..f979c143a3a 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.html @@ -32,6 +32,7 @@ formControlName="minLength" id="minLength" [min]="MinPasswordLength" + [max]="MaxPasswordLength" /> diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts new file mode 100644 index 00000000000..b22f5687dd2 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts @@ -0,0 +1,69 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { MasterPasswordPolicyComponent } from "./master-password.component"; + +describe("MasterPasswordPolicyComponent", () => { + let component: MasterPasswordPolicyComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + { provide: I18nService, useValue: mock() }, + { provide: OrganizationService, useValue: mock() }, + { provide: AccountService, useValue: mock() }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(MasterPasswordPolicyComponent); + component = fixture.componentInstance; + }); + + it("should accept minimum password length of 12", () => { + component.data.patchValue({ minLength: 12 }); + + expect(component.data.get("minLength")?.valid).toBe(true); + }); + + it("should accept maximum password length of 128", () => { + component.data.patchValue({ minLength: 128 }); + + expect(component.data.get("minLength")?.valid).toBe(true); + }); + + it("should reject password length below minimum", () => { + component.data.patchValue({ minLength: 11 }); + + expect(component.data.get("minLength")?.hasError("min")).toBe(true); + }); + + it("should reject password length above maximum", () => { + component.data.patchValue({ minLength: 129 }); + + expect(component.data.get("minLength")?.hasError("max")).toBe(true); + }); + + it("should use correct minimum from Utils", () => { + expect(component.MinPasswordLength).toBe(Utils.minimumPasswordLength); + expect(component.MinPasswordLength).toBe(12); + }); + + it("should use correct maximum from Utils", () => { + expect(component.MaxPasswordLength).toBe(Utils.maximumPasswordLength); + expect(component.MaxPasswordLength).toBe(128); + }); + + it("should have password scores from 0 to 4", () => { + const scores = component.passwordScores.filter((s) => s.value !== null).map((s) => s.value); + + expect(scores).toEqual([0, 1, 2, 3, 4]); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts index e9926b2aeb1..dd2463d718d 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts @@ -34,10 +34,14 @@ export class MasterPasswordPolicy extends BasePolicyEditDefinition { }) export class MasterPasswordPolicyComponent extends BasePolicyEditComponent implements OnInit { MinPasswordLength = Utils.minimumPasswordLength; + MaxPasswordLength = Utils.maximumPasswordLength; data: FormGroup> = this.formBuilder.group({ minComplexity: [null], - minLength: [this.MinPasswordLength, [Validators.min(Utils.minimumPasswordLength)]], + minLength: [ + this.MinPasswordLength, + [Validators.min(Utils.minimumPasswordLength), Validators.max(this.MaxPasswordLength)], + ], requireUpper: [false], requireLower: [false], requireNumbers: [false], diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 7b248eee8a3..d21b5039d2a 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -65,6 +65,7 @@ import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service"; import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; @@ -127,6 +128,8 @@ import { } from "@bitwarden/key-management"; import { LockComponentService, + WebAuthnPrfUnlockService, + DefaultWebAuthnPrfUnlockService, SessionTimeoutSettingsComponentService, } from "@bitwarden/key-management-ui"; import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; @@ -495,6 +498,21 @@ const safeProviders: SafeProvider[] = [ useClass: NoopAuthRequestAnsweringService, deps: [], }), + safeProvider({ + provide: WebAuthnPrfUnlockService, + useClass: DefaultWebAuthnPrfUnlockService, + deps: [ + WebAuthnLoginPrfKeyServiceAbstraction, + KeyServiceAbstraction, + InternalUserDecryptionOptionsServiceAbstraction, + EncryptService, + EnvironmentService, + PlatformUtilsService, + WINDOW, + LogService, + ConfigService, + ], + }), ]; @NgModule({ diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts index c8c6a54f2a6..36afd1850e0 100644 --- a/apps/web/src/app/core/event.service.ts +++ b/apps/web/src/app/core/event.service.ts @@ -522,16 +522,25 @@ export class EventService { break; // Org Domain claiming events case EventType.OrganizationDomain_Added: - msg = humanReadableMsg = this.i18nService.t("addedDomain", ev.domainName); + msg = humanReadableMsg = this.i18nService.t("addedDomain", this.escapeHtml(ev.domainName)); break; case EventType.OrganizationDomain_Removed: - msg = humanReadableMsg = this.i18nService.t("removedDomain", ev.domainName); + msg = humanReadableMsg = this.i18nService.t( + "removedDomain", + this.escapeHtml(ev.domainName), + ); break; case EventType.OrganizationDomain_Verified: - msg = humanReadableMsg = this.i18nService.t("domainClaimedEvent", ev.domainName); + msg = humanReadableMsg = this.i18nService.t( + "domainClaimedEvent", + this.escapeHtml(ev.domainName), + ); break; case EventType.OrganizationDomain_NotVerified: - msg = humanReadableMsg = this.i18nService.t("domainNotClaimedEvent", ev.domainName); + msg = humanReadableMsg = this.i18nService.t( + "domainNotClaimedEvent", + this.escapeHtml(ev.domainName), + ); break; // Secrets Manager case EventType.Secret_Retrieved: @@ -893,6 +902,15 @@ export class EventService { return id?.substring(0, 8); } + private escapeHtml(unsafe: string): string { + if (!unsafe) { + return unsafe; + } + const div = document.createElement("div"); + div.textContent = unsafe; + return div.innerHTML; + } + private toDateTimeLocalString(date: Date) { return ( date.getFullYear() + diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts index 929f1489a61..9322d149e42 100644 --- a/apps/web/src/app/core/init.service.ts +++ b/apps/web/src/app/core/init.service.ts @@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { IpcService } from "@bitwarden/common/platform/ipc"; @@ -40,7 +39,6 @@ export class InitService { private ipcService: IpcService, private sdkLoadService: SdkLoadService, private taskService: TaskService, - private configService: ConfigService, private readonly migrationRunner: MigrationRunner, @Inject(DOCUMENT) private document: Document, ) {} @@ -49,7 +47,6 @@ export class InitService { return async () => { await this.sdkLoadService.loadAndInit(); await this.migrationRunner.run(); - this.encryptService.init(this.configService); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); if (activeAccount) { diff --git a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts index d098be56663..f775ed84ede 100644 --- a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts @@ -46,8 +46,11 @@ export abstract class CipherReportComponent implements OnDestroy { organizations: Organization[] = []; organizations$: Observable; + readonly maxItemsToSwitchToChipSelect = 5; filterStatus: any = [0]; showFilterToggle: boolean = false; + selectedFilterChip: string = "0"; + chipSelectOptions: { label: string; value: string }[] = []; vaultMsg: string = "vault"; currentFilterStatus: number | string = 0; protected filterOrgStatus$ = new BehaviorSubject(0); @@ -190,6 +193,7 @@ export abstract class CipherReportComponent implements OnDestroy { formConfig, activeCollectionId, disableForm, + isAdminConsoleAction: true, }); const result = await lastValueFrom(this.vaultItemDialogRef.closed); @@ -288,6 +292,15 @@ export abstract class CipherReportComponent implements OnDestroy { return await this.cipherService.getAllDecrypted(activeUserId); } + protected canDisplayToggleGroup(): boolean { + return this.filterStatus.length <= this.maxItemsToSwitchToChipSelect; + } + + async filterOrgToggleChipSelect(filterId: string | null) { + const selectedFilterId = filterId ?? 0; + await this.filterOrgToggle(selectedFilterId); + } + protected filterCiphersByOrg(ciphersList: CipherView[]) { this.allCiphers = [...ciphersList]; @@ -309,5 +322,22 @@ export abstract class CipherReportComponent implements OnDestroy { this.showFilterToggle = false; this.vaultMsg = "vault"; } + + this.chipSelectOptions = this.setupChipSelectOptions(this.filterStatus); + } + + private setupChipSelectOptions(filters: string[]) { + const options = filters.map((filterId: string, index: number) => { + const name = this.getName(filterId); + const count = this.getCount(filterId); + const labelSuffix = count != null ? ` (${count})` : ""; + + return { + label: name + labelSuffix, + value: filterId, + }; + }); + + return options; } } diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html index fcdb3f6ca64..55e6678bd58 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html @@ -13,19 +13,32 @@ {{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - {{ getName(status) }} - {{ getCount(status) }} - - - + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + } @else { + + } + } + diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html index 9a99a55b77b..a1d3f2a38be 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html @@ -18,19 +18,32 @@ {{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - {{ getName(status) }} - {{ getCount(status) }} - - - + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + } @else { + + } + } + diff --git a/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts index 1d3d8d71f5a..6c81cbd9986 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts @@ -16,7 +16,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { PasswordRepromptService, CipherFormConfigService, @@ -45,7 +45,7 @@ import { ExposedPasswordsReportComponent as BaseExposedPasswordsReportComponent RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class ExposedPasswordsReportComponent extends BaseExposedPasswordsReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts index 23d1330dad7..6b93b289df9 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts @@ -11,7 +11,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService, @@ -39,7 +39,7 @@ import { InactiveTwoFactorReportComponent as BaseInactiveTwoFactorReportComponen RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class InactiveTwoFactorReportComponent extends BaseInactiveTwoFactorReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts index 599774d5515..0ae9ecad0cb 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts @@ -15,7 +15,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService, @@ -44,7 +44,7 @@ import { ReusedPasswordsReportComponent as BaseReusedPasswordsReportComponent } RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class ReusedPasswordsReportComponent extends BaseReusedPasswordsReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts index 6bf741b86eb..0b7cd3bfe7c 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts @@ -15,7 +15,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService, @@ -44,7 +44,7 @@ import { UnsecuredWebsitesReportComponent as BaseUnsecuredWebsitesReportComponen RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class UnsecuredWebsitesReportComponent extends BaseUnsecuredWebsitesReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts index 6780b65931c..411295ceb2a 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts @@ -16,7 +16,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService, @@ -45,7 +45,7 @@ import { WeakPasswordsReportComponent as BaseWeakPasswordsReportComponent } from RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class WeakPasswordsReportComponent extends BaseWeakPasswordsReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/reports-home.component.ts b/apps/web/src/app/dirt/reports/pages/reports-home.component.ts index a0e3a73aa3f..25cf663ba7e 100644 --- a/apps/web/src/app/dirt/reports/pages/reports-home.component.ts +++ b/apps/web/src/app/dirt/reports/pages/reports-home.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core"; import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -9,9 +9,8 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { reports, ReportType } from "../reports"; import { ReportEntry, ReportVariant } from "../shared"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "app-reports-home", templateUrl: "reports-home.component.html", standalone: false, diff --git a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html index d09dfa81fd4..62496dfad00 100644 --- a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html @@ -19,19 +19,30 @@ {{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - {{ getName(status) }} - {{ getCount(status) }} - - - + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + } @else { + + } + } diff --git a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html index cc7537333ad..276508b3801 100644 --- a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html @@ -19,19 +19,31 @@ {{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - {{ getName(status) }} - {{ getCount(status) }} - - - + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + } @else { + + } + } + diff --git a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html index 92d56c1c7a3..96bae4c3e0a 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html @@ -18,19 +18,32 @@ {{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - {{ getName(status) }} - {{ getCount(status) }} - - - + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + } @else { + + } + } + diff --git a/apps/web/src/app/dirt/reports/reports.module.ts b/apps/web/src/app/dirt/reports/reports.module.ts index 5648b40982a..4fc152917f4 100644 --- a/apps/web/src/app/dirt/reports/reports.module.ts +++ b/apps/web/src/app/dirt/reports/reports.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { ChipSelectComponent } from "@bitwarden/components"; import { CipherFormConfigService, DefaultCipherFormConfigService, @@ -34,6 +35,7 @@ import { ReportsSharedModule } from "./shared"; OrganizationBadgeModule, PipesModule, HeaderModule, + ChipSelectComponent, ], declarations: [ BreachReportComponent, diff --git a/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts b/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts index 9e993259830..a8e1830971e 100644 --- a/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts +++ b/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts @@ -5,6 +5,7 @@ import { firstValueFrom, of } from "rxjs"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { UserId } from "@bitwarden/common/types/guid"; import { BiometricsStatus } from "@bitwarden/key-management"; +import { WebAuthnPrfUnlockService } from "@bitwarden/key-management-ui"; import { WebLockComponentService } from "./web-lock-component.service"; @@ -12,9 +13,11 @@ describe("WebLockComponentService", () => { let service: WebLockComponentService; let userDecryptionOptionsService: MockProxy; + let webAuthnPrfUnlockService: MockProxy; beforeEach(() => { userDecryptionOptionsService = mock(); + webAuthnPrfUnlockService = mock(); TestBed.configureTestingModule({ providers: [ @@ -23,6 +26,10 @@ describe("WebLockComponentService", () => { provide: UserDecryptionOptionsServiceAbstraction, useValue: userDecryptionOptionsService, }, + { + provide: WebAuthnPrfUnlockService, + useValue: webAuthnPrfUnlockService, + }, ], }); @@ -91,6 +98,7 @@ describe("WebLockComponentService", () => { userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValueOnce( of(userDecryptionOptions), ); + webAuthnPrfUnlockService.isPrfUnlockAvailable.mockResolvedValue(false); const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); @@ -105,6 +113,9 @@ describe("WebLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported, }, + prf: { + enabled: false, + }, }); }); }); diff --git a/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts b/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts index ea038ca2c67..0451aa08689 100644 --- a/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts +++ b/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts @@ -1,16 +1,18 @@ import { inject } from "@angular/core"; -import { map, Observable } from "rxjs"; +import { combineLatest, defer, map, Observable } from "rxjs"; -import { - UserDecryptionOptions, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { UserId } from "@bitwarden/common/types/guid"; import { BiometricsStatus } from "@bitwarden/key-management"; -import { LockComponentService, UnlockOptions } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + UnlockOptions, + WebAuthnPrfUnlockService, +} from "@bitwarden/key-management-ui"; export class WebLockComponentService implements LockComponentService { private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction); + private readonly webAuthnPrfUnlockService = inject(WebAuthnPrfUnlockService); constructor() {} @@ -43,8 +45,14 @@ export class WebLockComponentService implements LockComponentService { } getAvailableUnlockOptions$(userId: UserId): Observable { - return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId)?.pipe( - map((userDecryptionOptions: UserDecryptionOptions) => { + return combineLatest([ + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + defer(async () => { + const available = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(userId); + return { available }; + }), + ]).pipe( + map(([userDecryptionOptions, prfUnlockInfo]) => { const unlockOpts: UnlockOptions = { masterPassword: { enabled: userDecryptionOptions.hasMasterPassword, @@ -56,6 +64,9 @@ export class WebLockComponentService implements LockComponentService { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported, }, + prf: { + enabled: prfUnlockInfo.available, + }, }; return unlockOpts; }), diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.html b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.html index fdfe8eb55ff..d1ee9d29ebd 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.html +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.html @@ -1,5 +1,5 @@ diff --git a/apps/web/src/app/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index a40cb3d4330..d65a8e997fd 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -23,7 +23,7 @@ diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html index b9326ca08ac..a484f210f62 100644 --- a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html @@ -17,7 +17,7 @@

- {{ "sendCreatedDescription" | i18n: formattedExpirationTime }} + {{ "sendCreatedDescriptionV2" | i18n: formattedExpirationTime }}

diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index e4030a7ab18..ec06c740f24 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -3,7 +3,7 @@ {{ title }} - @if (isCipherArchived) { + @if (isCipherArchived && !params.isAdminConsoleAction) { {{ "archived" | i18n }} } @@ -87,7 +87,7 @@ @if (showActionButtons) {
@if ((userCanArchive$ | async) && !params.isAdminConsoleAction) { - @if (isCipherArchived) { + @if (isCipherArchived && !cipher?.isDeleted) { + + + @if (canDelete) { +
+ +
+ } + + + diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts new file mode 100644 index 00000000000..9c5dc58a762 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts @@ -0,0 +1,206 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { IntegrationType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { IntegrationDialogResultStatus } from "../integration-dialog-result-status"; + +import { + ConnectHuntressDialogComponent, + HuntressConnectDialogParams, + HuntressConnectDialogResult, + openHuntressConnectDialog, +} from "./connect-dialog-huntress.component"; + +beforeAll(() => { + // Mock element.animate for jsdom + // the animate function is not available in jsdom, so we provide a mock implementation + // This is necessary for tests that rely on animations + // This mock does not perform any actual animations, it just provides a structure that allows tests + // to run without throwing errors related to missing animate function + if (!HTMLElement.prototype.animate) { + HTMLElement.prototype.animate = function () { + return { + play: () => {}, + pause: () => {}, + finish: () => {}, + cancel: () => {}, + reverse: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + onfinish: null, + oncancel: null, + startTime: 0, + currentTime: 0, + playbackRate: 1, + playState: "idle", + replaceState: "active", + effect: null, + finished: Promise.resolve(), + id: "", + remove: () => {}, + timeline: null, + ready: Promise.resolve(), + } as unknown as Animation; + }; + } +}); + +describe("ConnectHuntressDialogComponent", () => { + let component: ConnectHuntressDialogComponent; + let fixture: ComponentFixture; + let dialogRefMock = mock>(); + const mockI18nService = mock(); + + const integrationMock: Integration = { + name: "Huntress", + image: "test-image.png", + linkURL: "https://example.com", + imageDarkMode: "test-image-dark.png", + newBadgeExpiration: "2024-12-31", + description: "Test Description", + canSetupConnection: true, + type: IntegrationType.EVENT, + } as Integration; + + const connectInfo: HuntressConnectDialogParams = { + settings: integrationMock, + }; + + beforeEach(async () => { + dialogRefMock = mock>(); + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, SharedModule, BrowserAnimationsModule], + providers: [ + FormBuilder, + { provide: DIALOG_DATA, useValue: connectInfo }, + { provide: DialogRef, useValue: dialogRefMock }, + { provide: I18nPipe, useValue: mock() }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConnectHuntressDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + mockI18nService.t.mockImplementation((key) => key); + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize form with empty values and service name", () => { + expect(component.formGroup.value).toEqual({ + url: "", + token: "", + service: "Huntress", + }); + }); + + it("should have required validators for url and token fields", () => { + component.formGroup.setValue({ url: "", token: "", service: "" }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://hec.huntress.io/services/collector", + token: "test-token", + service: "Huntress", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should require url to be at least 7 characters long", () => { + component.formGroup.setValue({ + url: "test", + token: "token", + service: "Huntress", + }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://hec.huntress.io", + token: "token", + service: "Huntress", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should call dialogRef.close with correct result on submit", async () => { + component.formGroup.setValue({ + url: "https://hec.huntress.io/services/collector", + token: "test-token", + service: "Huntress", + }); + + await component.submit(); + + expect(dialogRefMock.close).toHaveBeenCalledWith({ + integrationSettings: integrationMock, + url: "https://hec.huntress.io/services/collector", + token: "test-token", + service: "Huntress", + success: IntegrationDialogResultStatus.Edited, + }); + }); + + it("should not submit when form is invalid", async () => { + component.formGroup.setValue({ + url: "", + token: "", + service: "Huntress", + }); + + await component.submit(); + + expect(dialogRefMock.close).not.toHaveBeenCalled(); + expect(component.formGroup.touched).toBeTruthy(); + }); + + it("should return false for isUpdateAvailable when no config exists", () => { + component.huntressConfig = null; + expect(component.isUpdateAvailable).toBeFalsy(); + }); + + it("should return true for isUpdateAvailable when config exists", () => { + component.huntressConfig = { uri: "test", token: "test" } as any; + expect(component.isUpdateAvailable).toBeTruthy(); + }); + + it("should return false for canDelete when no config exists", () => { + component.huntressConfig = null; + expect(component.canDelete).toBeFalsy(); + }); + + it("should return true for canDelete when config exists", () => { + component.huntressConfig = { uri: "test", token: "test" } as any; + expect(component.canDelete).toBeTruthy(); + }); +}); + +describe("openHuntressConnectDialog", () => { + it("should call dialogService.open with correct params", () => { + const dialogServiceMock = mock(); + const config: DialogConfig< + HuntressConnectDialogParams, + DialogRef + > = { + data: { settings: { name: "Huntress" } as Integration }, + } as any; + + openHuntressConnectDialog(dialogServiceMock, config); + + expect(dialogServiceMock.open).toHaveBeenCalledWith(ConnectHuntressDialogComponent, config); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts new file mode 100644 index 00000000000..953a8cdb0ac --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts @@ -0,0 +1,114 @@ +import { ChangeDetectionStrategy, Component, Inject, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; + +import { HecConfiguration } from "@bitwarden/bit-common/dirt/organization-integrations/models/configuration/hec-configuration"; +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { + IntegrationDialogResultStatus, + IntegrationDialogResultStatusType, +} from "../integration-dialog-result-status"; + +export type HuntressConnectDialogParams = { + settings: Integration; +}; + +export interface HuntressConnectDialogResult { + integrationSettings: Integration; + url: string; + token: string; + service: string; + success: IntegrationDialogResultStatusType | null; +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./connect-dialog-huntress.component.html", + imports: [SharedModule], +}) +export class ConnectHuntressDialogComponent implements OnInit { + loading = false; + huntressConfig: HecConfiguration | null = null; + formGroup = this.formBuilder.group({ + url: ["", [Validators.required, Validators.minLength(7)]], + token: ["", Validators.required], + service: [""], // Programmatically set in ngOnInit, not shown to user + }); + + constructor( + @Inject(DIALOG_DATA) protected connectInfo: HuntressConnectDialogParams, + protected formBuilder: FormBuilder, + private dialogRef: DialogRef, + private dialogService: DialogService, + ) {} + + ngOnInit(): void { + this.huntressConfig = + this.connectInfo.settings.organizationIntegration?.getConfiguration() ?? + null; + + this.formGroup.patchValue({ + url: this.huntressConfig?.uri || "", + token: this.huntressConfig?.token || "", + service: this.connectInfo.settings.name, + }); + } + + get isUpdateAvailable(): boolean { + return !!this.huntressConfig; + } + + get canDelete(): boolean { + return !!this.huntressConfig; + } + + submit = async (): Promise => { + if (this.formGroup.invalid) { + this.formGroup.markAllAsTouched(); + return; + } + const result = this.getHuntressConnectDialogResult(IntegrationDialogResultStatus.Edited); + + this.dialogRef.close(result); + + return; + }; + + delete = async (): Promise => { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { + key: "deleteItemConfirmation", + }, + type: "warning", + }); + + if (confirmed) { + const result = this.getHuntressConnectDialogResult(IntegrationDialogResultStatus.Delete); + this.dialogRef.close(result); + } + }; + + private getHuntressConnectDialogResult( + status: IntegrationDialogResultStatusType, + ): HuntressConnectDialogResult { + const formJson = this.formGroup.getRawValue(); + + return { + integrationSettings: this.connectInfo.settings, + url: formJson.url || "", + token: formJson.token || "", + service: formJson.service || "", + success: status, + }; + } +} + +export function openHuntressConnectDialog( + dialogService: DialogService, + config: DialogConfig>, +) { + return dialogService.open(ConnectHuntressDialogComponent, config); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts index 9852f3fe5c8..a41ee826cbc 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts @@ -1,2 +1,4 @@ export * from "./connect-dialog/connect-dialog-hec.component"; export * from "./connect-dialog/connect-dialog-datadog.component"; +export * from "./connect-dialog/connect-dialog-huntress.component"; +export * from "./integration-dialog-result-status"; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts new file mode 100644 index 00000000000..1774088c203 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts @@ -0,0 +1,11 @@ +/** + * Shared status types for integration dialog results + * Used across all SIEM integration dialogs (HEC, Datadog, Huntress, etc.) + */ +export const IntegrationDialogResultStatus = { + Edited: "edit", + Delete: "delete", +} as const; + +export type IntegrationDialogResultStatusType = + (typeof IntegrationDialogResultStatus)[keyof typeof IntegrationDialogResultStatus]; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts index 6517182b21e..5485410f735 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -32,6 +32,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { tabIndex: number = 0; organization$: Observable = new Observable(); isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false; + isEventManagementForHuntressEnabled: boolean = false; private destroy$ = new Subject(); // initialize the integrations list with default integrations @@ -258,6 +259,13 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.isEventManagementForDataDogAndCrowdStrikeEnabled = isEnabled; }); + this.configService + .getFeatureFlag$(FeatureFlag.EventManagementForHuntress) + .pipe(takeUntil(this.destroy$)) + .subscribe((isEnabled) => { + this.isEventManagementForHuntressEnabled = isEnabled; + }); + // Add the new event based items to the list if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) { const crowdstrikeIntegration: Integration = { @@ -285,6 +293,21 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.integrationsList.push(datadogIntegration); } + // Add Huntress SIEM integration (separate feature flag) + if (this.isEventManagementForHuntressEnabled) { + const huntressIntegration: Integration = { + name: OrganizationIntegrationServiceName.Huntress, + linkURL: "https://bitwarden.com/help/huntress-siem/", + image: "../../../../../../../images/integrations/logo-huntress-siem.svg", + type: IntegrationType.EVENT, + description: "huntressEventIntegrationDesc", + canSetupConnection: true, + integrationType: OrganizationIntegrationType.Hec, + }; + + this.integrationsList.push(huntressIntegration); + } + // For all existing event based configurations loop through and assign the // organizationIntegration for the correct services. this.organizationIntegrationService.integrations$ diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index ac55e8ecb11..1ecf7fe3e3d 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -896,7 +896,7 @@ const safeProviders: SafeProvider[] = [ FolderApiServiceAbstraction, InternalOrganizationServiceAbstraction, SendApiServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, + InternalUserDecryptionOptionsServiceAbstraction, AvatarServiceAbstraction, LOGOUT_CALLBACK, BillingAccountProfileStateService, diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index 563fd48028d..c4fe2741e11 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -194,12 +194,7 @@ export class VaultItemsComponent implements OnDestroy return this.searchService.searchCiphers( userId, searchText, - [ - filter, - this.deletedFilter, - ...(this.deleted ? [] : [this.archivedFilter]), - restrictedTypeFilter, - ], + [filter, restrictedTypeFilter], allCiphers, ); }), diff --git a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts index 83693c85239..d3ad29142e2 100644 --- a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts +++ b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts @@ -54,6 +54,12 @@ export class VaultFilter { cipherPassesFilter = CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher); } + + if (this.status !== "archive" && this.status !== "trash" && cipherPassesFilter) { + cipherPassesFilter = + !CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher); + } + if (this.cipherType != null && cipherPassesFilter) { cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType; } diff --git a/libs/assets/src/svg/svgs/admin-console.ts b/libs/assets/src/svg/svgs/admin-console.ts index 83c8cf9f0e1..3e8f47ec4a5 100644 --- a/libs/assets/src/svg/svgs/admin-console.ts +++ b/libs/assets/src/svg/svgs/admin-console.ts @@ -2,13 +2,13 @@ import { svgIcon } from "../icon-service"; const AdminConsoleLogo = svgIcon` - + - + diff --git a/libs/assets/src/svg/svgs/password-manager.ts b/libs/assets/src/svg/svgs/password-manager.ts index 17b6f148be3..5b19562e022 100644 --- a/libs/assets/src/svg/svgs/password-manager.ts +++ b/libs/assets/src/svg/svgs/password-manager.ts @@ -2,13 +2,13 @@ import { svgIcon } from "../icon-service"; const PasswordManagerLogo = svgIcon` - + - + diff --git a/libs/assets/src/svg/svgs/provider-portal.ts b/libs/assets/src/svg/svgs/provider-portal.ts index 51c04e1553b..fad2ce6b864 100644 --- a/libs/assets/src/svg/svgs/provider-portal.ts +++ b/libs/assets/src/svg/svgs/provider-portal.ts @@ -2,13 +2,13 @@ import { svgIcon } from "../icon-service"; const ProviderPortalLogo = svgIcon` - + - + diff --git a/libs/assets/src/svg/svgs/secrets-manager.ts b/libs/assets/src/svg/svgs/secrets-manager.ts index 27589e7e2f9..62b54174c55 100644 --- a/libs/assets/src/svg/svgs/secrets-manager.ts +++ b/libs/assets/src/svg/svgs/secrets-manager.ts @@ -2,13 +2,13 @@ import { svgIcon } from "../icon-service"; const SecretsManagerLogo = svgIcon` - + - + diff --git a/libs/assets/src/svg/svgs/shield.ts b/libs/assets/src/svg/svgs/shield.ts index 38d429604aa..af626a98e9d 100644 --- a/libs/assets/src/svg/svgs/shield.ts +++ b/libs/assets/src/svg/svgs/shield.ts @@ -3,11 +3,11 @@ import { svgIcon } from "../icon-service"; const BitwardenShield = svgIcon` - + - + diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index 2ae79f46d7c..94d2c6b65aa 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -175,6 +175,8 @@ describe("WebAuthnLoginStrategy", () => { WebAuthnPrfOption: { EncryptedPrivateKey: mockEncPrfPrivateKey, EncryptedUserKey: mockEncUserKey, + CredentialId: "mockCredentialId", + Transports: ["usb", "nfc"], }, }; diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts index 77a881b5964..019e1d9860e 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -73,14 +73,15 @@ export class WebAuthnLoginStrategy extends LoginStrategy { const userDecryptionOptions = idTokenResponse?.userDecryptionOptions; if (userDecryptionOptions?.webAuthnPrfOption) { - const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption; - const credentials = this.cache.value.credentials; + // confirm we still have the prf key if (!credentials.prfKey) { return; } + const webAuthnPrfOption = userDecryptionOptions.webAuthnPrfOption; + // decrypt prf encrypted private key const privateKey = await this.encryptService.unwrapDecapsulationKey( webAuthnPrfOption.encryptedPrivateKey, diff --git a/libs/auth/src/common/models/domain/user-decryption-options.ts b/libs/auth/src/common/models/domain/user-decryption-options.ts index 44d8bff4d2c..561a833f3c9 100644 --- a/libs/auth/src/common/models/domain/user-decryption-options.ts +++ b/libs/auth/src/common/models/domain/user-decryption-options.ts @@ -5,6 +5,7 @@ import { Jsonify } from "type-fest"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { KeyConnectorUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response"; import { TrustedDeviceUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response"; +import { WebAuthnPrfDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response"; /** * Key Connector decryption options. Intended to be sent to the client for use after authentication. @@ -45,6 +46,61 @@ export class KeyConnectorUserDecryptionOption { } } +/** + * Trusted device decryption options. Intended to be sent to the client for use after authentication. + * @see {@link UserDecryptionOptions} + */ +/** + * WebAuthn PRF decryption options. Intended to be sent to the client for use after authentication. + * @see {@link UserDecryptionOptions} + */ +export class WebAuthnPrfUserDecryptionOption { + /** The encrypted private key that can be decrypted with the PRF key. */ + encryptedPrivateKey: string; + /** The encrypted user key that can be decrypted with the private key. */ + encryptedUserKey: string; + /** The credential ID for this WebAuthn PRF credential. */ + credentialId: string; + /** The transports supported by this credential. */ + transports: string[]; + + /** + * Initializes a new instance of the WebAuthnPrfUserDecryptionOption from a response object. + * @param response The WebAuthn PRF user decryption option response object. + * @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `response` is nullish. + */ + static fromResponse( + response: WebAuthnPrfDecryptionOptionResponse, + ): WebAuthnPrfUserDecryptionOption | undefined { + if (response == null) { + return undefined; + } + if (!response.encryptedPrivateKey || !response.encryptedUserKey) { + return undefined; + } + const options = new WebAuthnPrfUserDecryptionOption(); + options.encryptedPrivateKey = response.encryptedPrivateKey.encryptedString; + options.encryptedUserKey = response.encryptedUserKey.encryptedString; + options.credentialId = response.credentialId; + options.transports = response.transports || []; + return options; + } + + /** + * Initializes a new instance of a WebAuthnPrfUserDecryptionOption from a JSON object. + * @param obj JSON object to deserialize. + * @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `obj` is nullish. + */ + static fromJSON( + obj: Jsonify, + ): WebAuthnPrfUserDecryptionOption | undefined { + if (obj == null) { + return undefined; + } + return Object.assign(new WebAuthnPrfUserDecryptionOption(), obj); + } +} + /** * Trusted device decryption options. Intended to be sent to the client for use after authentication. * @see {@link UserDecryptionOptions} @@ -104,6 +160,8 @@ export class UserDecryptionOptions { trustedDeviceOption?: TrustedDeviceUserDecryptionOption; /** {@link KeyConnectorUserDecryptionOption} */ keyConnectorOption?: KeyConnectorUserDecryptionOption; + /** Array of {@link WebAuthnPrfUserDecryptionOption} */ + webAuthnPrfOptions?: WebAuthnPrfUserDecryptionOption[]; /** * Initializes a new instance of the UserDecryptionOptions from a response object. @@ -134,6 +192,18 @@ export class UserDecryptionOptions { decryptionOptions.keyConnectorOption = KeyConnectorUserDecryptionOption.fromResponse( responseOptions.keyConnectorOption, ); + + // The IdTokenResponse only returns a single WebAuthn PRF option to support immediate unlock after logging in + // with the same PRF passkey. + // Since our domain model supports multiple WebAuthn PRF options, we convert the single option into an array. + if (responseOptions.webAuthnPrfOption) { + const option = WebAuthnPrfUserDecryptionOption.fromResponse( + responseOptions.webAuthnPrfOption, + ); + if (option) { + decryptionOptions.webAuthnPrfOptions = [option]; + } + } } else { throw new Error( "User Decryption Options are required for client initialization. userDecryptionOptions is missing in response.", @@ -158,6 +228,12 @@ export class UserDecryptionOptions { obj?.keyConnectorOption, ); + if (obj?.webAuthnPrfOptions && Array.isArray(obj.webAuthnPrfOptions)) { + decryptionOptions.webAuthnPrfOptions = obj.webAuthnPrfOptions + .map((option) => WebAuthnPrfUserDecryptionOption.fromJSON(option)) + .filter((option) => option !== undefined); + } + return decryptionOptions; } } diff --git a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts index 4ebadc0daa9..4c5a67d2c31 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts @@ -27,6 +27,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse { masterPasswordUnlock?: MasterPasswordUnlockResponse; trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse; keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse; + /** + * The IdTokenresponse only returns a single WebAuthn PRF option. + * To support immediate unlock after logging in with the same PRF passkey. + */ webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse; constructor(response: IUserDecryptionOptionsServerResponse) { diff --git a/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts b/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts index 478f6d88b5b..b2b5a57ce8f 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts @@ -6,19 +6,30 @@ import { BaseResponse } from "../../../../models/response/base.response"; export interface IWebAuthnPrfDecryptionOptionServerResponse { EncryptedPrivateKey: string; EncryptedUserKey: string; + CredentialId: string; + Transports: string[]; } export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse { encryptedPrivateKey: EncString; encryptedUserKey: EncString; + credentialId: string; + transports: string[]; constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) { super(response); - if (response.EncryptedPrivateKey) { - this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey")); + + const encPrivateKey = this.getResponseProperty("EncryptedPrivateKey"); + if (encPrivateKey) { + this.encryptedPrivateKey = new EncString(encPrivateKey); } - if (response.EncryptedUserKey) { - this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey")); + + const encUserKey = this.getResponseProperty("EncryptedUserKey"); + if (encUserKey) { + this.encryptedUserKey = new EncString(encUserKey); } + + this.credentialId = this.getResponseProperty("CredentialId"); + this.transports = this.getResponseProperty("Transports") || []; } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ea52e50b2ed..0086524a47f 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -40,9 +40,9 @@ export enum FeatureFlag { PrivateKeyRegeneration = "pm-12241-private-key-regeneration", EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation", ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", - PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption", LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2", NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change", + PasskeyUnlock = "pm-2035-passkey-unlock", DataRecoveryTool = "pm-28813-data-recovery-tool", ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component", PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit", @@ -57,6 +57,7 @@ export enum FeatureFlag { /* DIRT */ EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike", + EventManagementForHuntress = "event-management-for-huntress", PhishingDetection = "phishing-detection", /* Vault */ @@ -64,8 +65,6 @@ export enum FeatureFlag { PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", CipherKeyEncryption = "cipher-key-encryption", - RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", - VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems", PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk", @@ -121,6 +120,7 @@ export const DefaultFeatureFlagValue = { /* DIRT */ [FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE, + [FeatureFlag.EventManagementForHuntress]: FALSE, [FeatureFlag.PhishingDetection]: FALSE, /* Vault */ @@ -128,8 +128,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, - [FeatureFlag.RiskInsightsForPremium]: FALSE, - [FeatureFlag.VaultLoadingSkeletons]: FALSE, [FeatureFlag.BrowserPremiumSpotlight]: FALSE, [FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE, [FeatureFlag.MigrateMyVaultToMyItems]: FALSE, @@ -152,9 +150,9 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.EnrollAeadOnKeyRotation]: FALSE, [FeatureFlag.ForceUpdateKDFSettings]: FALSE, - [FeatureFlag.PM25174_DisableType0Decryption]: FALSE, [FeatureFlag.LinuxBiometricsV2]: FALSE, [FeatureFlag.NoLogoutOnKdfChange]: FALSE, + [FeatureFlag.PasskeyUnlock]: FALSE, [FeatureFlag.DataRecoveryTool]: FALSE, [FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE, [FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE, diff --git a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts index 25e5f949b40..87af3852116 100644 --- a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts @@ -1,16 +1,8 @@ -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; - import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { EncString } from "../models/enc-string"; export abstract class EncryptService { - /** - * A temporary init method to make the encrypt service listen to feature-flag changes. - * This will be removed once the feature flag has been rolled out. - */ - abstract init(configService: ConfigService): void; - /** * Encrypts a string to an EncString * @param plainValue - The value to encrypt diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts index a5da0c82382..b14211b5b72 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts @@ -1,9 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; @@ -15,28 +13,12 @@ import { PureCrypto } from "@bitwarden/sdk-internal"; import { EncryptService } from "../abstractions/encrypt.service"; export class EncryptServiceImplementation implements EncryptService { - private disableType0Decryption = false; - constructor( protected cryptoFunctionService: CryptoFunctionService, protected logService: LogService, protected logMacFailures: boolean, ) {} - init(configService: ConfigService): void { - configService.serverConfig$.subscribe((newConfig) => { - if (newConfig != null) { - this.setDisableType0Decryption( - newConfig.featureStates[FeatureFlag.PM25174_DisableType0Decryption] === true, - ); - } - }); - } - - setDisableType0Decryption(disable: boolean): void { - this.disableType0Decryption = disable; - } - async encryptString(plainValue: string, key: SymmetricCryptoKey): Promise { if (plainValue == null) { this.logService.warning( @@ -60,7 +42,7 @@ export class EncryptServiceImplementation implements EncryptService { } async decryptString(encString: EncString, key: SymmetricCryptoKey): Promise { - if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) { + if (encString.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } await SdkLoadService.Ready; @@ -68,7 +50,7 @@ export class EncryptServiceImplementation implements EncryptService { } async decryptBytes(encString: EncString, key: SymmetricCryptoKey): Promise { - if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) { + if (encString.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } await SdkLoadService.Ready; @@ -76,7 +58,7 @@ export class EncryptServiceImplementation implements EncryptService { } async decryptFileData(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise { - if (this.disableType0Decryption && encBuffer.encryptionType === EncryptionType.AesCbc256_B64) { + if (encBuffer.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } await SdkLoadService.Ready; @@ -148,10 +130,7 @@ export class EncryptServiceImplementation implements EncryptService { throw new Error("No wrappingKey provided for unwrapping."); } - if ( - this.disableType0Decryption && - wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64 - ) { + if (wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } @@ -171,10 +150,7 @@ export class EncryptServiceImplementation implements EncryptService { if (wrappingKey == null) { throw new Error("No wrappingKey provided for unwrapping."); } - if ( - this.disableType0Decryption && - wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64 - ) { + if (wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } @@ -194,10 +170,7 @@ export class EncryptServiceImplementation implements EncryptService { if (wrappingKey == null) { throw new Error("No wrappingKey provided for unwrapping."); } - if ( - this.disableType0Decryption && - keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64 - ) { + if (keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts index 466f59da7c9..ac1f4d6ada0 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts @@ -163,7 +163,7 @@ describe("EncryptService", () => { describe("decryptString", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("encrypted_string"); + const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_string"); const result = await encryptService.decryptString(encString, key); expect(result).toEqual("decrypted_string"); expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith( @@ -172,8 +172,7 @@ describe("EncryptService", () => { ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_string"); await expect(encryptService.decryptString(encString, key)).rejects.toThrow( @@ -185,7 +184,7 @@ describe("EncryptService", () => { describe("decryptBytes", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("encrypted_bytes"); + const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_bytes"); const result = await encryptService.decryptBytes(encString, key); expect(result).toEqual(new Uint8Array(3)); expect(PureCrypto.symmetric_decrypt_bytes).toHaveBeenCalledWith( @@ -194,8 +193,7 @@ describe("EncryptService", () => { ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_bytes"); await expect(encryptService.decryptBytes(encString, key)).rejects.toThrow( @@ -216,8 +214,7 @@ describe("EncryptService", () => { ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encBuffer = EncArrayBuffer.fromParts( EncryptionType.AesCbc256_B64, @@ -234,7 +231,10 @@ describe("EncryptService", () => { describe("unwrapDecapsulationKey", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("wrapped_decapsulation_key"); + const encString = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "wrapped_decapsulation_key", + ); const result = await encryptService.unwrapDecapsulationKey(encString, key); expect(result).toEqual(new Uint8Array(4)); expect(PureCrypto.unwrap_decapsulation_key).toHaveBeenCalledWith( @@ -242,8 +242,7 @@ describe("EncryptService", () => { key.toEncoded(), ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_decapsulation_key"); await expect(encryptService.unwrapDecapsulationKey(encString, key)).rejects.toThrow( @@ -267,7 +266,10 @@ describe("EncryptService", () => { describe("unwrapEncapsulationKey", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("wrapped_encapsulation_key"); + const encString = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "wrapped_encapsulation_key", + ); const result = await encryptService.unwrapEncapsulationKey(encString, key); expect(result).toEqual(new Uint8Array(5)); expect(PureCrypto.unwrap_encapsulation_key).toHaveBeenCalledWith( @@ -275,8 +277,7 @@ describe("EncryptService", () => { key.toEncoded(), ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_encapsulation_key"); await expect(encryptService.unwrapEncapsulationKey(encString, key)).rejects.toThrow( @@ -300,7 +301,10 @@ describe("EncryptService", () => { describe("unwrapSymmetricKey", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("wrapped_symmetric_key"); + const encString = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "wrapped_symmetric_key", + ); const result = await encryptService.unwrapSymmetricKey(encString, key); expect(result).toEqual(new SymmetricCryptoKey(new Uint8Array(64))); expect(PureCrypto.unwrap_symmetric_key).toHaveBeenCalledWith( @@ -308,8 +312,7 @@ describe("EncryptService", () => { key.toEncoded(), ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_symmetric_key"); await expect(encryptService.unwrapSymmetricKey(encString, key)).rejects.toThrow( diff --git a/libs/common/src/key-management/models/response/user-decryption.response.ts b/libs/common/src/key-management/models/response/user-decryption.response.ts index b3ac5b80b32..b662834ab01 100644 --- a/libs/common/src/key-management/models/response/user-decryption.response.ts +++ b/libs/common/src/key-management/models/response/user-decryption.response.ts @@ -1,9 +1,15 @@ +import { WebAuthnPrfDecryptionOptionResponse } from "../../../auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response"; import { BaseResponse } from "../../../models/response/base.response"; import { MasterPasswordUnlockResponse } from "../../master-password/models/response/master-password-unlock.response"; export class UserDecryptionResponse extends BaseResponse { masterPasswordUnlock?: MasterPasswordUnlockResponse; + /** + * The sync service returns an array of WebAuthn PRF options. + */ + webAuthnPrfOptions?: WebAuthnPrfDecryptionOptionResponse[]; + constructor(response: unknown) { super(response); @@ -11,5 +17,12 @@ export class UserDecryptionResponse extends BaseResponse { if (masterPasswordUnlock != null && typeof masterPasswordUnlock === "object") { this.masterPasswordUnlock = new MasterPasswordUnlockResponse(masterPasswordUnlock); } + + const webAuthnPrfOptions = this.getResponseProperty("WebAuthnPrfOptions"); + if (webAuthnPrfOptions != null && Array.isArray(webAuthnPrfOptions)) { + this.webAuthnPrfOptions = webAuthnPrfOptions.map( + (option) => new WebAuthnPrfDecryptionOptionResponse(option), + ); + } } } diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts index ccb66a4dff4..3c391344f04 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts @@ -260,6 +260,13 @@ describe("VaultTimeoutSettingsService", () => { }); describe("getVaultTimeoutByUserId$", () => { + beforeEach(() => { + // Return the input value unchanged + sessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation( + async (timeout) => timeout, + ); + }); + it("should throw an error if no user id is provided", async () => { expect(() => vaultTimeoutSettingsService.getVaultTimeoutByUserId$(null)).toThrow( "User id required. Cannot get vault timeout.", @@ -277,6 +284,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + defaultVaultTimeout, + ); expect(result).toBe(defaultVaultTimeout); }); @@ -299,8 +309,31 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + vaultTimeout, + ); expect(result).toBe(vaultTimeout); }); + + it("promotes timeout when unavailable on client", async () => { + const determinedTimeout = VaultTimeoutNumberType.OnMinute; + const promotedValue = VaultTimeoutStringType.OnRestart; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue(of([])); + + await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + determinedTimeout, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: custom", () => { @@ -327,6 +360,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + policyMinutes, + ); expect(result).toBe(policyMinutes); }, ); @@ -345,6 +381,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + vaultTimeout, + ); expect(result).toBe(vaultTimeout); }, ); @@ -365,8 +404,36 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutNumberType.Immediately, + ); expect(result).toBe(VaultTimeoutNumberType.Immediately); }); + + it("promotes policy minutes when unavailable on client", async () => { + const promotedValue = VaultTimeoutStringType.Never; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState( + VAULT_TIMEOUT, + VaultTimeoutNumberType.EightHours, + mockUserId, + ); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + policyMinutes, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: immediately", () => { @@ -383,7 +450,6 @@ describe("VaultTimeoutSettingsService", () => { "when current timeout is %s, returns immediately or promoted value", async (currentTimeout) => { const expectedTimeout = VaultTimeoutNumberType.Immediately; - sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); policyService.policiesByType$.mockReturnValue( of([{ data: { type: "immediately" } }] as unknown as Policy[]), ); @@ -400,6 +466,26 @@ describe("VaultTimeoutSettingsService", () => { expect(result).toBe(expectedTimeout); }, ); + + it("promotes immediately when unavailable on client", async () => { + const promotedValue = VaultTimeoutNumberType.OnMinute; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "immediately" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutNumberType.Immediately, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: onSystemLock", () => { @@ -413,7 +499,6 @@ describe("VaultTimeoutSettingsService", () => { "when current timeout is %s, returns onLocked or promoted value", async (currentTimeout) => { const expectedTimeout = VaultTimeoutStringType.OnLocked; - sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); policyService.policiesByType$.mockReturnValue( of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]), ); @@ -446,9 +531,31 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + currentTimeout, + ); expect(result).toBe(currentTimeout); }); + + it("promotes onLocked when unavailable on client", async () => { + const promotedValue = VaultTimeoutStringType.OnRestart; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutStringType.OnLocked, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: onAppRestart", () => { @@ -468,7 +575,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutStringType.OnRestart, + ); expect(result).toBe(VaultTimeoutStringType.OnRestart); }); @@ -488,32 +597,40 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + currentTimeout, + ); expect(result).toBe(currentTimeout); }); - }); - describe("policy type: never", () => { - it("when current timeout is never, returns never or promoted value", async () => { - const expectedTimeout = VaultTimeoutStringType.Never; - sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); + it("promotes onRestart when unavailable on client", async () => { + const promotedValue = VaultTimeoutStringType.Never; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); policyService.policiesByType$.mockReturnValue( - of([{ data: { type: "never" } }] as unknown as Policy[]), + of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]), ); - await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + await stateProvider.setUserState( + VAULT_TIMEOUT, + VaultTimeoutStringType.OnLocked, + mockUserId, + ); const result = await firstValueFrom( vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( - VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, ); - expect(result).toBe(expectedTimeout); + expect(result).toBe(promotedValue); }); + }); + describe("policy type: never", () => { it.each([ + VaultTimeoutStringType.Never, VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnIdle, @@ -532,9 +649,32 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + currentTimeout, + ); expect(result).toBe(currentTimeout); }); + + it("promotes timeout when unavailable on client", async () => { + const determinedTimeout = VaultTimeoutStringType.Never; + const promotedValue = VaultTimeoutStringType.OnRestart; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "never" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + determinedTimeout, + ); + expect(result).toBe(promotedValue); + }); }); }); diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts index b8bc859d11c..57e484fd767 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts @@ -179,7 +179,20 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA private async determineVaultTimeout( currentVaultTimeout: VaultTimeout | null, maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, - ): Promise { + ): Promise { + const determinedTimeout = await this.determineVaultTimeoutInternal( + currentVaultTimeout, + maxSessionTimeoutPolicyData, + ); + + // Ensures the timeout is available on this client + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(determinedTimeout); + } + + private async determineVaultTimeoutInternal( + currentVaultTimeout: VaultTimeout | null, + maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, + ): Promise { // if current vault timeout is null, apply the client specific default currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout; @@ -190,9 +203,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA switch (maxSessionTimeoutPolicyData.type) { case "immediately": - return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( - VaultTimeoutNumberType.Immediately, - ); + return VaultTimeoutNumberType.Immediately; case "custom": case null: case undefined: @@ -211,9 +222,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA currentVaultTimeout === VaultTimeoutStringType.OnIdle || currentVaultTimeout === VaultTimeoutStringType.OnSleep ) { - return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( - VaultTimeoutStringType.OnLocked, - ); + return VaultTimeoutStringType.OnLocked; } break; case "onAppRestart": @@ -227,11 +236,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA } break; case "never": - if (currentVaultTimeout === VaultTimeoutStringType.Never) { - return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( - VaultTimeoutStringType.Never, - ); - } + // Policy doesn't override user preference for "never" break; } return currentVaultTimeout; diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts index 136b0ac394f..bdbfc4ea17b 100644 --- a/libs/common/src/platform/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -42,6 +42,7 @@ export class Utils { static readonly validHosts: string[] = ["localhost"]; static readonly originalMinimumPasswordLength = 8; static readonly minimumPasswordLength = 12; + static readonly maximumPasswordLength = 128; static readonly DomainMatchBlacklist = new Map>([ ["google.com", new Set(["script.google.com"])], ]); diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index fc83954ee7d..bf086ceceaf 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -9,7 +9,7 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { LogoutReason, UserDecryptionOptions, - UserDecryptionOptionsServiceAbstraction, + InternalUserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -68,7 +68,7 @@ describe("DefaultSyncService", () => { let folderApiService: MockProxy; let organizationService: MockProxy; let sendApiService: MockProxy; - let userDecryptionOptionsService: MockProxy; + let userDecryptionOptionsService: MockProxy; let avatarService: MockProxy; let logoutCallback: jest.Mock, [logoutReason: LogoutReason, userId?: UserId]>; let billingAccountProfileStateService: MockProxy; diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 3c8f6e57e1e..52de14bbc67 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -6,8 +6,8 @@ import { firstValueFrom, map } from "rxjs"; // eslint-disable-next-line no-restricted-imports import { CollectionService } from "@bitwarden/admin-console/common"; import { - CollectionDetailsResponse, CollectionData, + CollectionDetailsResponse, } from "@bitwarden/common/admin-console/models/collections"; import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service"; import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; @@ -15,9 +15,13 @@ import { SecurityStateService } from "@bitwarden/common/key-management/security- // eslint-disable-next-line no-restricted-imports import { KdfConfigService, KeyService } from "@bitwarden/key-management"; -// FIXME: remove `src` and fix import +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { UserDecryptionOptionsServiceAbstraction } from "../../../../auth/src/common/abstractions"; +import { + InternalUserDecryptionOptionsServiceAbstraction, + UserDecryptionOptions, + WebAuthnPrfUserDecryptionOption, +} from "../../../../auth/src/common"; // FIXME: remove `src` and fix import // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "../../../../auth/src/common/types"; @@ -93,7 +97,7 @@ export class DefaultSyncService extends CoreSyncService { folderApiService: FolderApiServiceAbstraction, private organizationService: InternalOrganizationServiceAbstraction, sendApiService: SendApiService, - private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, private avatarService: AvatarService, private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise, private billingAccountProfileStateService: BillingAccountProfileStateService, @@ -450,5 +454,43 @@ export class DefaultSyncService extends CoreSyncService { ); await this.kdfConfigService.setKdfConfig(userId, masterPasswordUnlockData.kdf); } + + // Update WebAuthn PRF options if present + if (userDecryption.webAuthnPrfOptions != null && userDecryption.webAuthnPrfOptions.length > 0) { + try { + // Only update if this is the active user, since setUserDecryptionOptions() + // operates on the active user's state + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + if (activeAccount?.id !== userId) { + return; + } + + // Get current options without blocking if they don't exist yet + const currentUserDecryptionOptions = await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + ).catch((): UserDecryptionOptions | null => { + return null; + }); + + if (currentUserDecryptionOptions != null) { + // Update the PRF options while preserving other decryption options + const updatedOptions = Object.assign( + new UserDecryptionOptions(), + currentUserDecryptionOptions, + ); + updatedOptions.webAuthnPrfOptions = userDecryption.webAuthnPrfOptions + .map((option) => WebAuthnPrfUserDecryptionOption.fromResponse(option)) + .filter((option) => option !== undefined); + + await this.userDecryptionOptionsService.setUserDecryptionOptionsById( + activeAccount.id, + updatedOptions, + ); + } + } catch (error) { + this.logService.error("[Sync] Failed to update WebAuthn PRF options:", error); + } + } } } diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 3e8166ba312..4b544b2a34e 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -230,13 +230,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider; abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise; abstract delete(id: string | string[], userId: UserId): Promise; - abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; abstract deleteManyWithServer( ids: string[], userId: UserId, asAdmin?: boolean, orgId?: OrganizationId, - ): Promise; + ): Promise; abstract deleteAttachment( id: string, revisionDate: string, @@ -252,19 +252,19 @@ export abstract class CipherService implements UserKeyRotationDataProvider number; - abstract softDelete(id: string | string[], userId: UserId): Promise; - abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + abstract softDelete(id: string | string[], userId: UserId): Promise; + abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; abstract softDeleteManyWithServer( ids: string[], userId: UserId, asAdmin?: boolean, orgId?: OrganizationId, - ): Promise; + ): Promise; abstract restore( cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[], userId: UserId, - ): Promise; - abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + ): Promise; + abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise; abstract getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise; abstract setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId): Promise; diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 0a83f7101aa..b35a1573b06 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -224,6 +224,9 @@ describe("Cipher Service", () => { }); it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); const spy = jest .spyOn(apiService, "postCipherAdmin") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); @@ -236,6 +239,9 @@ describe("Cipher Service", () => { }); it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); encryptionContext.cipher.organizationId = null!; const spy = jest .spyOn(apiService, "postCipher") @@ -249,6 +255,9 @@ describe("Cipher Service", () => { }); it("should call apiService.postCipherCreate if collectionsIds != null", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); encryptionContext.cipher.collectionIds = ["123"]; const spy = jest .spyOn(apiService, "postCipherCreate") @@ -262,6 +271,9 @@ describe("Cipher Service", () => { }); it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); const spy = jest .spyOn(apiService, "postCipher") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); @@ -328,6 +340,9 @@ describe("Cipher Service", () => { }); it("should call apiService.putCipher if cipher.edit is true", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); encryptionContext.cipher.edit = true; const spy = jest .spyOn(apiService, "putCipher") @@ -341,6 +356,9 @@ describe("Cipher Service", () => { }); it("should call apiService.putPartialCipher when orgAdmin, and edit are false", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); encryptionContext.cipher.edit = false; const spy = jest .spyOn(apiService, "putPartialCipher") diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index b132dfb23ec..b27be4326a0 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1446,10 +1446,12 @@ export class CipherService implements CipherServiceAbstraction { await this.encryptedCiphersState(userId).update(() => ciphers); } - async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); if (useSdk) { - return this.deleteWithServerUsingSdk(id, userId, asAdmin); + await this.cipherSdkService.deleteWithServer(id, userId, asAdmin); + await this.clearCache(userId); + return; } if (asAdmin) { @@ -1461,24 +1463,17 @@ export class CipherService implements CipherServiceAbstraction { await this.delete(id, userId); } - private async deleteWithServerUsingSdk( - id: string, - userId: UserId, - asAdmin = false, - ): Promise { - await this.cipherSdkService.deleteWithServer(id, userId, asAdmin); - await this.clearCache(userId); - } - async deleteManyWithServer( ids: string[], userId: UserId, asAdmin = false, orgId?: OrganizationId, - ): Promise { + ): Promise { const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); if (useSdk) { - return this.deleteManyWithServerUsingSdk(ids, userId, asAdmin, orgId); + await this.cipherSdkService.deleteManyWithServer(ids, userId, asAdmin, orgId); + await this.clearCache(userId); + return; } const request = new CipherBulkDeleteRequest(ids); @@ -1490,16 +1485,6 @@ export class CipherService implements CipherServiceAbstraction { await this.delete(ids, userId); } - private async deleteManyWithServerUsingSdk( - ids: string[], - userId: UserId, - asAdmin = false, - orgId?: OrganizationId, - ): Promise { - await this.cipherSdkService.deleteManyWithServer(ids, userId, asAdmin, orgId); - await this.clearCache(userId); - } - async deleteAttachment( id: string, revisionDate: string, @@ -1630,7 +1615,7 @@ export class CipherService implements CipherServiceAbstraction { }; } - async softDelete(id: string | string[], userId: UserId): Promise { + async softDelete(id: string | string[], userId: UserId): Promise { let ciphers = await firstValueFrom(this.ciphers$(userId)); if (ciphers == null) { return; @@ -1658,10 +1643,12 @@ export class CipherService implements CipherServiceAbstraction { }); } - async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); if (useSdk) { - return this.softDeleteWithServerUsingSdk(id, userId, asAdmin); + await this.cipherSdkService.softDeleteWithServer(id, userId, asAdmin); + await this.clearCache(userId); + return; } if (asAdmin) { @@ -1673,24 +1660,17 @@ export class CipherService implements CipherServiceAbstraction { await this.softDelete(id, userId); } - private async softDeleteWithServerUsingSdk( - id: string, - userId: UserId, - asAdmin = false, - ): Promise { - await this.cipherSdkService.softDeleteWithServer(id, userId, asAdmin); - await this.clearCache(userId); - } - async softDeleteManyWithServer( ids: string[], userId: UserId, asAdmin = false, orgId?: OrganizationId, - ): Promise { + ): Promise { const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); if (useSdk) { - return this.softDeleteManyWithServerUsingSdk(ids, userId, asAdmin, orgId); + await this.cipherSdkService.softDeleteManyWithServer(ids, userId, asAdmin, orgId); + await this.clearCache(userId); + return; } const request = new CipherBulkDeleteRequest(ids); @@ -1703,16 +1683,6 @@ export class CipherService implements CipherServiceAbstraction { await this.softDelete(ids, userId); } - private async softDeleteManyWithServerUsingSdk( - ids: string[], - userId: UserId, - asAdmin = false, - orgId?: OrganizationId, - ): Promise { - await this.cipherSdkService.softDeleteManyWithServer(ids, userId, asAdmin, orgId); - await this.clearCache(userId); - } - async restore( cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[], userId: UserId, @@ -1746,10 +1716,12 @@ export class CipherService implements CipherServiceAbstraction { }); } - async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise { + async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise { const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); if (useSdk) { - return await this.restoreWithServerUsingSdk(id, userId, asAdmin); + await this.cipherSdkService.restoreWithServer(id, userId, asAdmin); + await this.clearCache(userId); + return; } let response; @@ -1762,15 +1734,6 @@ export class CipherService implements CipherServiceAbstraction { await this.restore({ id: id, revisionDate: response.revisionDate }, userId); } - private async restoreWithServerUsingSdk( - id: string, - userId: UserId, - asAdmin = false, - ): Promise { - await this.cipherSdkService.restoreWithServer(id, userId, asAdmin); - await this.clearCache(userId); - } - /** * No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable * The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore @@ -1778,7 +1741,9 @@ export class CipherService implements CipherServiceAbstraction { async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise { const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); if (useSdk) { - return await this.restoreManyWithServerUsingSdk(ids, userId, orgId); + await this.cipherSdkService.restoreManyWithServer(ids, userId, orgId); + await this.clearCache(userId); + return; } let response; @@ -1798,15 +1763,6 @@ export class CipherService implements CipherServiceAbstraction { await this.restore(restores, userId); } - private async restoreManyWithServerUsingSdk( - ids: string[], - userId: UserId, - orgId?: string, - ): Promise { - await this.cipherSdkService.restoreManyWithServer(ids, userId, orgId); - await this.clearCache(userId); - } - async getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise { if (cipher.organizationId == null) { return await firstValueFrom(this.keyService.userKey$(userId)); diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 553da0c541b..84140a8953a 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -7,10 +7,10 @@ import { Icon } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Translation } from "../dialog"; +import { LandingContentMaxWidthType } from "../landing-layout"; import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service"; -import { AnonLayoutComponent, AnonLayoutMaxWidth } from "./anon-layout.component"; - +import { AnonLayoutComponent } from "./anon-layout.component"; export interface AnonLayoutWrapperData { /** * The optional title of the page. @@ -35,7 +35,7 @@ export interface AnonLayoutWrapperData { /** * Optional flag to set the max-width of the page. Defaults to 'md' if not provided. */ - maxWidth?: AnonLayoutMaxWidth; + maxWidth?: LandingContentMaxWidthType; /** * Hide the card that wraps the default content. Defaults to false. */ @@ -59,7 +59,7 @@ export class AnonLayoutWrapperComponent implements OnInit { protected pageSubtitle?: string | null; protected pageIcon: Icon | null = null; protected showReadonlyHostname?: boolean | null; - protected maxWidth?: AnonLayoutMaxWidth | null; + protected maxWidth?: LandingContentMaxWidthType | null; protected hideCardWrapper?: boolean | null; protected hideBackgroundIllustration?: boolean | null; diff --git a/libs/components/src/anon-layout/anon-layout.component.html b/libs/components/src/anon-layout/anon-layout.component.html index 6bd72a25382..932ff10832c 100644 --- a/libs/components/src/anon-layout/anon-layout.component.html +++ b/libs/components/src/anon-layout/anon-layout.component.html @@ -1,76 +1,26 @@ -
-
- @if (!hideLogo()) { - - - - } -
- -
-
+ + + + -
- @let iconInput = icon(); - - - -
- -
- - @if (title()) { - -

- {{ title() }} -

- -

- {{ title() }} -

- } - - @if (subtitle()) { -
{{ subtitle() }}
- } -
- -
+ + @if (hideCardWrapper()) {
} @else { - + - + } - -
+
+ +
+ @if (!hideFooter()) { -
+ @if (showReadonlyHostname()) {
{{ "accessing" | i18n }} {{ hostname }}
} @else { @@ -81,22 +31,9 @@
© {{ year }} Bitwarden Inc.
{{ version }}
} -
+ } - - @if (!hideBackgroundIllustration()) { -
- -
-
- -
- } -
+ diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index e6572a0c3c1..eded556cd53 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -11,23 +11,17 @@ import { import { RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; -import { - BackgroundLeftIllustration, - BackgroundRightIllustration, - BitwardenLogo, - Icon, -} from "@bitwarden/assets/svg"; +import { BitwardenLogo, Icon } from "@bitwarden/assets/svg"; import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { BaseCardComponent } from "../card"; import { IconModule } from "../icon"; +import { LandingContentMaxWidthType } from "../landing-layout"; +import { LandingLayoutModule } from "../landing-layout/landing-layout.module"; import { SharedModule } from "../shared"; import { TypographyModule } from "../typography"; -export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; - // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -39,7 +33,7 @@ export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; TypographyModule, SharedModule, RouterModule, - BaseCardComponent, + LandingLayoutModule, ], }) export class AnonLayoutComponent implements OnInit, OnChanges { @@ -49,9 +43,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges { return ["tw-h-full"]; } - readonly leftIllustration = BackgroundLeftIllustration; - readonly rightIllustration = BackgroundRightIllustration; - readonly title = input(); readonly subtitle = input(); readonly icon = model.required(); @@ -66,7 +57,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { * * @default 'md' */ - readonly maxWidth = model("md"); + readonly maxWidth = model("md"); protected logo = BitwardenLogo; protected year: string; @@ -76,24 +67,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges { protected hideYearAndVersion = false; - get maxWidthClass(): string { - const maxWidth = this.maxWidth(); - switch (maxWidth) { - case "md": - return "tw-max-w-md"; - case "lg": - return "tw-max-w-lg"; - case "xl": - return "tw-max-w-xl"; - case "2xl": - return "tw-max-w-2xl"; - case "3xl": - return "tw-max-w-3xl"; - case "4xl": - return "tw-max-w-4xl"; - } - } - constructor( private environmentService: EnvironmentService, private platformUtilsService: PlatformUtilsService, diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index c7eb28fc086..3b5e01132a2 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -71,9 +71,9 @@ const styles: Record = { primary: ["!tw-text-primary-600", "focus-visible:before:tw-ring-primary-600", ...focusRing], danger: ["!tw-text-danger-600", "focus-visible:before:tw-ring-primary-600", ...focusRing], "nav-contrast": [ - "!tw-text-alt2", + "!tw-text-fg-sidenav-text", "hover:!tw-bg-hover-contrast", - "focus-visible:before:tw-ring-text-alt2", + "focus-visible:before:tw-ring-border-focus", ...focusRing, ], }; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 23fb5beb456..9c4dadadd4b 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -25,6 +25,7 @@ export * from "./icon"; export * from "./icon-tile"; export * from "./input"; export * from "./item"; +export * from "./landing-layout"; export * from "./layout"; export * from "./link"; export * from "./menu"; diff --git a/libs/components/src/landing-layout/index.ts b/libs/components/src/landing-layout/index.ts new file mode 100644 index 00000000000..49b3d24631d --- /dev/null +++ b/libs/components/src/landing-layout/index.ts @@ -0,0 +1,7 @@ +export * from "./landing-layout.component"; +export * from "./landing-layout.module"; +export * from "./landing-card.component"; +export * from "./landing-content.component"; +export * from "./landing-footer.component"; +export * from "./landing-header.component"; +export * from "./landing-hero.component"; diff --git a/libs/components/src/landing-layout/landing-card.component.html b/libs/components/src/landing-layout/landing-card.component.html new file mode 100644 index 00000000000..bea783489bf --- /dev/null +++ b/libs/components/src/landing-layout/landing-card.component.html @@ -0,0 +1,5 @@ + + + diff --git a/libs/components/src/landing-layout/landing-card.component.ts b/libs/components/src/landing-layout/landing-card.component.ts new file mode 100644 index 00000000000..cea04f6f784 --- /dev/null +++ b/libs/components/src/landing-layout/landing-card.component.ts @@ -0,0 +1,33 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +import { BaseCardComponent } from "../card"; + +/** + * Card component for landing pages that wraps content in a styled container. + * + * @remarks + * This component provides: + * - Card-based layout with consistent styling + * - Content projection for forms, text, or other content + * - Proper elevation and border styling + * + * Use this component inside `bit-landing-content` to wrap forms, content sections, + * or any content that should appear in a contained, elevated card. + * + * @example + * ```html + * + *
+ * + *
+ *
+ * ``` + */ +@Component({ + selector: "bit-landing-card", + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [BaseCardComponent], + templateUrl: "./landing-card.component.html", +}) +export class LandingCardComponent {} diff --git a/libs/components/src/landing-layout/landing-content.component.html b/libs/components/src/landing-layout/landing-content.component.html new file mode 100644 index 00000000000..a09db26e4e4 --- /dev/null +++ b/libs/components/src/landing-layout/landing-content.component.html @@ -0,0 +1,8 @@ +
+
+ + +
+
diff --git a/libs/components/src/landing-layout/landing-content.component.ts b/libs/components/src/landing-layout/landing-content.component.ts new file mode 100644 index 00000000000..940e4b01f53 --- /dev/null +++ b/libs/components/src/landing-layout/landing-content.component.ts @@ -0,0 +1,63 @@ +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; + +export const LandingContentMaxWidth = ["md", "lg", "xl", "2xl", "3xl", "4xl"] as const; + +export type LandingContentMaxWidthType = (typeof LandingContentMaxWidth)[number]; + +/** + * Main content container for landing pages with configurable max-width constraints. + * + * @remarks + * This component provides: + * - Centered content area with alternative background color + * - Configurable maximum width to control content readability + * - Content projection slots for hero section and main content + * - Responsive padding and layout + * + * Use this component inside `bit-landing-layout` to wrap your main page content. + * Optionally include a `bit-landing-hero` as the first child for consistent hero section styling. + * + * @example + * ```html + * + * + * + * + * + * + * ``` + */ +@Component({ + selector: "bit-landing-content", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-content.component.html", + host: { + class: "tw-grow tw-flex tw-flex-col", + }, +}) +export class LandingContentComponent { + /** + * Max width of the landing layout container. + * + * @default "md" + */ + readonly maxWidth = input("md"); + + private readonly maxWidthClassMap: Record = { + md: "tw-max-w-md", + lg: "tw-max-w-lg", + xl: "tw-max-w-xl", + "2xl": "tw-max-w-2xl", + "3xl": "tw-max-w-3xl", + "4xl": "tw-max-w-4xl", + }; + + readonly maxWidthClasses = computed(() => { + const maxWidthClass = this.maxWidthClassMap[this.maxWidth()]; + return `tw-flex tw-flex-col tw-w-full ${maxWidthClass}`; + }); +} diff --git a/libs/components/src/landing-layout/landing-footer.component.html b/libs/components/src/landing-layout/landing-footer.component.html new file mode 100644 index 00000000000..c0230a93171 --- /dev/null +++ b/libs/components/src/landing-layout/landing-footer.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/libs/components/src/landing-layout/landing-footer.component.ts b/libs/components/src/landing-layout/landing-footer.component.ts new file mode 100644 index 00000000000..f18199bd280 --- /dev/null +++ b/libs/components/src/landing-layout/landing-footer.component.ts @@ -0,0 +1,29 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +/** + * Footer component for landing pages. + * + * @remarks + * This component provides: + * - Content projection for custom footer content (e.g., links, copyright, legal) + * - Consistent footer positioning at the bottom of the page + * - Proper z-index to appear above background illustrations + * + * Use this component inside `bit-landing-layout` as the last child to position it at the bottom. + * + * @example + * ```html + * + *
+ * Privacy + * © 2024 Bitwarden + *
+ *
+ * ``` + */ +@Component({ + selector: "bit-landing-footer", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-footer.component.html", +}) +export class LandingFooterComponent {} diff --git a/libs/components/src/landing-layout/landing-header.component.html b/libs/components/src/landing-layout/landing-header.component.html new file mode 100644 index 00000000000..ed6d34ef23b --- /dev/null +++ b/libs/components/src/landing-layout/landing-header.component.html @@ -0,0 +1,13 @@ +
+ @if (!hideLogo()) { + + + + } +
+ +
+
diff --git a/libs/components/src/landing-layout/landing-header.component.ts b/libs/components/src/landing-layout/landing-header.component.ts new file mode 100644 index 00000000000..eb5329e915d --- /dev/null +++ b/libs/components/src/landing-layout/landing-header.component.ts @@ -0,0 +1,42 @@ +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { BitwardenLogo } from "@bitwarden/assets/svg"; + +import { IconModule } from "../icon"; +import { SharedModule } from "../shared"; + +/** + * Header component for landing pages with optional Bitwarden logo and header actions slot. + * + * @remarks + * This component provides: + * - Optional Bitwarden logo with link to home page (left-aligned) + * - Default content projection slot for header actions (right-aligned, auto-margin left) + * - Consistent header styling across landing pages + * - Responsive layout that adapts logo size + * + * Use this component inside `bit-landing-layout` as the first child to position it at the top. + * Content projected into this component will automatically align to the right side of the header. + * + * @example + * ```html + * + * + * + * + * ``` + */ +@Component({ + selector: "bit-landing-header", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-header.component.html", + imports: [RouterModule, IconModule, SharedModule], +}) +export class LandingHeaderComponent { + readonly hideLogo = input(false); + protected readonly logo = BitwardenLogo; +} diff --git a/libs/components/src/landing-layout/landing-hero.component.html b/libs/components/src/landing-layout/landing-hero.component.html new file mode 100644 index 00000000000..dbce6a7c585 --- /dev/null +++ b/libs/components/src/landing-layout/landing-hero.component.html @@ -0,0 +1,28 @@ +@if (icon() || title() || subtitle()) { +
+ @if (icon()) { + + +
+ +
+ } + + @if (title()) { + +

+ {{ title() }} +

+ +

+ {{ title() }} +

+ } + + @if (subtitle()) { +
{{ subtitle() }}
+ } +
+} diff --git a/libs/components/src/landing-layout/landing-hero.component.ts b/libs/components/src/landing-layout/landing-hero.component.ts new file mode 100644 index 00000000000..b29e9768efd --- /dev/null +++ b/libs/components/src/landing-layout/landing-hero.component.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; + +import { Icon } from "@bitwarden/assets/svg"; + +import { IconModule } from "../icon"; +import { TypographyModule } from "../typography"; + +/** + * Hero section component for landing pages featuring an optional icon, title, and subtitle. + * + * @remarks + * This component provides: + * - Optional icon display (e.g., feature icons, status icons) + * - Large title text with consistent typography + * - Subtitle text for additional context + * - Centered layout with proper spacing + * + * Use this component as the first child inside `bit-landing-content` to create a prominent + * hero section that introduces the page's purpose. + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: "bit-landing-hero", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-hero.component.html", + imports: [IconModule, TypographyModule], +}) +export class LandingHeroComponent { + readonly icon = input(null); + readonly title = input(); + readonly subtitle = input(); +} diff --git a/libs/components/src/landing-layout/landing-layout.component.html b/libs/components/src/landing-layout/landing-layout.component.html new file mode 100644 index 00000000000..1164f538116 --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.component.html @@ -0,0 +1,25 @@ +
+ +
+ +
+ @if (!hideBackgroundIllustration()) { +
+ +
+
+ +
+ } + +
diff --git a/libs/components/src/landing-layout/landing-layout.component.ts b/libs/components/src/landing-layout/landing-layout.component.ts new file mode 100644 index 00000000000..520cca945d6 --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.component.ts @@ -0,0 +1,40 @@ +import { Component, ChangeDetectionStrategy, inject, input } from "@angular/core"; + +import { BackgroundLeftIllustration, BackgroundRightIllustration } from "@bitwarden/assets/svg"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { IconModule } from "../icon"; + +/** + * Root layout component for landing pages providing a full-screen container with optional decorative background illustrations. + * + * @remarks + * This component serves as the outermost wrapper for landing pages and provides: + * - Full-screen layout that adapts to different client types (web, browser, desktop) + * - Optional decorative background illustrations in the bottom corners + * - Content projection slots for header, main content, and footer + * + * @example + * ```html + * + * ... + * ... + * ... + * + * ``` + */ +@Component({ + selector: "bit-landing-layout", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-layout.component.html", + imports: [IconModule], +}) +export class LandingLayoutComponent { + readonly hideBackgroundIllustration = input(false); + + protected readonly leftIllustration = BackgroundLeftIllustration; + protected readonly rightIllustration = BackgroundRightIllustration; + + private readonly platformUtilsService: PlatformUtilsService = inject(PlatformUtilsService); + protected readonly clientType = this.platformUtilsService.getClientType(); +} diff --git a/libs/components/src/landing-layout/landing-layout.module.ts b/libs/components/src/landing-layout/landing-layout.module.ts new file mode 100644 index 00000000000..d225b8b35e1 --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from "@angular/core"; + +import { LandingCardComponent } from "./landing-card.component"; +import { LandingContentComponent } from "./landing-content.component"; +import { LandingFooterComponent } from "./landing-footer.component"; +import { LandingHeaderComponent } from "./landing-header.component"; +import { LandingHeroComponent } from "./landing-hero.component"; +import { LandingLayoutComponent } from "./landing-layout.component"; + +@NgModule({ + imports: [ + LandingLayoutComponent, + LandingHeaderComponent, + LandingHeroComponent, + LandingFooterComponent, + LandingContentComponent, + LandingCardComponent, + ], + exports: [ + LandingLayoutComponent, + LandingHeaderComponent, + LandingHeroComponent, + LandingFooterComponent, + LandingContentComponent, + LandingCardComponent, + ], +}) +export class LandingLayoutModule {} diff --git a/libs/components/src/landing-layout/landing-layout.stories.ts b/libs/components/src/landing-layout/landing-layout.stories.ts new file mode 100644 index 00000000000..7ea9598a64a --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.stories.ts @@ -0,0 +1,162 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; + +import { ClientType } from "@bitwarden/common/enums"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { ButtonModule } from "../button"; + +import { LandingLayoutComponent } from "./landing-layout.component"; + +class MockPlatformUtilsService implements Partial { + getClientType = () => ClientType.Web; +} + +type StoryArgs = LandingLayoutComponent & { + contentLength: "normal" | "long" | "thin"; + includeHeader: boolean; + includeFooter: boolean; +}; + +export default { + title: "Component Library/Landing Layout", + component: LandingLayoutComponent, + decorators: [ + moduleMetadata({ + imports: [ButtonModule], + providers: [ + { + provide: PlatformUtilsService, + useClass: MockPlatformUtilsService, + }, + ], + }), + ], + render: (args) => { + return { + props: args, + template: /*html*/ ` + + @if (includeHeader) { + +
+
+
Header Content
+
+
+
+ } + +
+ @switch (contentLength) { + @case ('thin') { +
+
Thin Content
+
+ } + @case ('long') { +
+
Long Content
+
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
+
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
+
+ } + @default { +
+
Normal Content
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
+
+ } + } +
+ + @if (includeFooter) { + +
+
Footer Content
+
+
+ } +
+ `, + }; + }, + + argTypes: { + hideBackgroundIllustration: { control: "boolean" }, + contentLength: { + control: "radio", + options: ["normal", "long", "thin"], + }, + includeHeader: { control: "boolean" }, + includeFooter: { control: "boolean" }, + }, + + args: { + hideBackgroundIllustration: false, + contentLength: "normal", + includeHeader: false, + includeFooter: false, + }, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + contentLength: "normal", + }, +}; + +export const WithHeader: Story = { + args: { + includeHeader: true, + }, +}; + +export const WithFooter: Story = { + args: { + includeFooter: true, + }, +}; + +export const WithHeaderAndFooter: Story = { + args: { + includeHeader: true, + includeFooter: true, + }, +}; + +export const LongContent: Story = { + args: { + contentLength: "long", + includeHeader: true, + includeFooter: true, + }, +}; + +export const ThinContent: Story = { + args: { + contentLength: "thin", + includeHeader: true, + includeFooter: true, + }, +}; + +export const NoBackgroundIllustration: Story = { + args: { + hideBackgroundIllustration: true, + includeHeader: true, + includeFooter: true, + }, +}; + +export const MinimalState: Story = { + args: { + contentLength: "thin", + hideBackgroundIllustration: true, + includeHeader: false, + includeFooter: false, + }, +}; diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index 66bfcafafe9..f0e2b601e38 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -30,21 +30,14 @@ - @if ( - { - open: sideNavService.open$ | async, - }; - as data - ) { -
- @if (data.open) { -
- } -
- } +
+ @if (sideNavService.open()) { +
+ } +
diff --git a/libs/components/src/navigation/nav-base.component.ts b/libs/components/src/navigation/nav-base.component.ts index 706df2b25ad..e20edf5a0f9 100644 --- a/libs/components/src/navigation/nav-base.component.ts +++ b/libs/components/src/navigation/nav-base.component.ts @@ -1,8 +1,11 @@ -import { Directive, EventEmitter, Output, input, model } from "@angular/core"; +import { Directive, output, input, model } from "@angular/core"; import { RouterLink, RouterLinkActive } from "@angular/router"; /** - * `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties that are passed down to `NavItemComponent`. + * Base class for navigation components in the side navigation. + * + * `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties + * that are passed down to `NavItemComponent`. */ @Directive() export abstract class NavBaseComponent { @@ -38,23 +41,26 @@ export abstract class NavBaseComponent { * * --- * + * @remarks * We can't name this "routerLink" because Angular will mount the `RouterLink` directive. * - * See: {@link https://github.com/angular/angular/issues/24482} + * @see {@link RouterLink.routerLink} + * @see {@link https://github.com/angular/angular/issues/24482} */ readonly route = input(); /** * Passed to internal `routerLink` * - * See {@link RouterLink.relativeTo} + * @see {@link RouterLink.relativeTo} */ readonly relativeTo = input(); /** * Passed to internal `routerLink` * - * See {@link RouterLinkActive.routerLinkActiveOptions} + * @default { paths: "subset", queryParams: "ignored", fragment: "ignored", matrixParams: "ignored" } + * @see {@link RouterLinkActive.routerLinkActiveOptions} */ readonly routerLinkActiveOptions = input({ paths: "subset", @@ -71,7 +77,5 @@ export abstract class NavBaseComponent { /** * Fires when main content is clicked */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref - @Output() mainContentClicked: EventEmitter = new EventEmitter(); + readonly mainContentClicked = output(); } diff --git a/libs/components/src/navigation/nav-divider.component.html b/libs/components/src/navigation/nav-divider.component.html index 2d8e1dfa24b..7af7de2a28a 100644 --- a/libs/components/src/navigation/nav-divider.component.html +++ b/libs/components/src/navigation/nav-divider.component.html @@ -1,3 +1,3 @@ -@if (sideNavService.open$ | async) { +@if (sideNavService.open()) {
} diff --git a/libs/components/src/navigation/nav-divider.component.ts b/libs/components/src/navigation/nav-divider.component.ts index 2f33883fd58..05a69563312 100644 --- a/libs/components/src/navigation/nav-divider.component.ts +++ b/libs/components/src/navigation/nav-divider.component.ts @@ -1,15 +1,15 @@ -import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; import { SideNavService } from "./side-nav.service"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +/** + * A visual divider for separating navigation items in the side navigation. + */ @Component({ selector: "bit-nav-divider", templateUrl: "./nav-divider.component.html", - imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavDividerComponent { - constructor(protected sideNavService: SideNavService) {} + protected readonly sideNavService = inject(SideNavService); } diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index 1790fea179a..26d1c68da43 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -19,10 +19,8 @@ - + } @if (open) {
@@ -88,3 +59,27 @@
} + + + +
+ @if (icon()) { + + } + @if (open) { + {{ text() }} + } +
+
diff --git a/libs/components/src/navigation/nav-item.component.ts b/libs/components/src/navigation/nav-item.component.ts index b32ca0e3fde..53b181ec083 100644 --- a/libs/components/src/navigation/nav-item.component.ts +++ b/libs/components/src/navigation/nav-item.component.ts @@ -1,7 +1,14 @@ -import { CommonModule } from "@angular/common"; -import { Component, HostListener, Optional, computed, input, model } from "@angular/core"; -import { RouterLinkActive, RouterModule } from "@angular/router"; -import { BehaviorSubject, map } from "rxjs"; +import { NgTemplateOutlet } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + input, + inject, + signal, + computed, + model, +} from "@angular/core"; +import { RouterModule, RouterLinkActive } from "@angular/router"; import { IconButtonModule } from "../icon-button"; @@ -14,13 +21,16 @@ export abstract class NavGroupAbstraction { abstract treeDepth: ReturnType>; } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-nav-item", templateUrl: "./nav-item.component.html", providers: [{ provide: NavBaseComponent, useExisting: NavItemComponent }], - imports: [CommonModule, IconButtonModule, RouterModule], + imports: [NgTemplateOutlet, IconButtonModule, RouterModule], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "(focusin)": "onFocusIn($event.target)", + "(focusout)": "onFocusOut()", + }, }) export class NavItemComponent extends NavBaseComponent { /** @@ -35,9 +45,14 @@ export class NavItemComponent extends NavBaseComponent { */ protected readonly TREE_DEPTH_PADDING = 1.75; - /** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */ + /** + * Forces active styles to be shown, regardless of the `routerLinkActiveOptions` + */ readonly forceActiveStyles = input(false); + protected readonly sideNavService = inject(SideNavService); + private readonly parentNavGroup = inject(NavGroupAbstraction, { optional: true }); + /** * Is `true` if `to` matches the current route */ @@ -56,7 +71,7 @@ export class NavItemComponent extends NavBaseComponent { * adding calculation for tree variant due to needing visual alignment on different indentation levels needed between the first level and subsequent levels */ protected readonly navItemIndentationPadding = computed(() => { - const open = this.sideNavService.open; + const open = this.sideNavService.open(); const depth = this.treeDepth() ?? 0; if (open && this.variant() === "tree") { @@ -87,25 +102,22 @@ export class NavItemComponent extends NavBaseComponent { * (denoted with the data-fvw attribute) matches :focus-visible. We then map that state to some * styles, so the entire component can have an outline. */ - protected focusVisibleWithin$ = new BehaviorSubject(false); - protected fvwStyles$ = this.focusVisibleWithin$.pipe( - map((value) => - value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-text-alt2" : "", - ), + protected readonly focusVisibleWithin = signal(false); + protected readonly fvwStyles = computed(() => + this.focusVisibleWithin() + ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus" + : "", ); - @HostListener("focusin", ["$event.target"]) - onFocusIn(target: HTMLElement) { - this.focusVisibleWithin$.next(target.matches("[data-fvw]:focus-visible")); - } - @HostListener("focusout") - onFocusOut() { - this.focusVisibleWithin$.next(false); + + protected onFocusIn(target: HTMLElement) { + this.focusVisibleWithin.set(target.matches("[data-fvw]:focus-visible")); } - constructor( - protected sideNavService: SideNavService, - @Optional() private parentNavGroup: NavGroupAbstraction, - ) { + protected onFocusOut() { + this.focusVisibleWithin.set(false); + } + + constructor() { super(); // Set tree depth based on parent's depth diff --git a/libs/components/src/navigation/nav-logo.component.html b/libs/components/src/navigation/nav-logo.component.html index 5915f029357..9f18855ae13 100644 --- a/libs/components/src/navigation/nav-logo.component.html +++ b/libs/components/src/navigation/nav-logo.component.html @@ -1,22 +1,21 @@ diff --git a/libs/components/src/navigation/nav-logo.component.ts b/libs/components/src/navigation/nav-logo.component.ts index 0602e8b753c..fec50ee8902 100644 --- a/libs/components/src/navigation/nav-logo.component.ts +++ b/libs/components/src/navigation/nav-logo.component.ts @@ -1,5 +1,4 @@ -import { CommonModule } from "@angular/common"; -import { Component, input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, inject } from "@angular/core"; import { RouterLinkActive, RouterLink } from "@angular/router"; import { BitwardenShield, Icon } from "@bitwarden/assets/svg"; @@ -8,18 +7,25 @@ import { BitIconComponent } from "../icon/icon.component"; import { SideNavService } from "./side-nav.service"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-nav-logo", templateUrl: "./nav-logo.component.html", - imports: [CommonModule, RouterLinkActive, RouterLink, BitIconComponent], + imports: [RouterLinkActive, RouterLink, BitIconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavLogoComponent { - /** Icon that is displayed when the side nav is closed */ + protected readonly sideNavService = inject(SideNavService); + + /** + * Icon that is displayed when the side nav is closed + * + * @default BitwardenShield + */ readonly closedIcon = input(BitwardenShield); - /** Icon that is displayed when the side nav is open */ + /** + * Icon that is displayed when the side nav is open + */ readonly openIcon = input.required(); /** @@ -27,8 +33,8 @@ export class NavLogoComponent { */ readonly route = input.required(); - /** Passed to `attr.aria-label` and `attr.title` */ + /** + * Passed to `attr.aria-label` and `attr.title` + */ readonly label = input.required(); - - constructor(protected sideNavService: SideNavService) {} } diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index 38ec096fe49..78fed07011d 100644 --- a/libs/components/src/navigation/side-nav.component.html +++ b/libs/components/src/navigation/side-nav.component.html @@ -1,68 +1,60 @@ -@if ( - { - open: sideNavService.open$ | async, - isOverlay: sideNavService.isOverlay$ | async, - }; - as data -) { -
- +@let open = sideNavService.open(); +@let isOverlay = sideNavService.isOverlay(); + +
+
-} + class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav" + > + + @if (open) { + + } +
+ +
+
+ + + diff --git a/libs/components/src/navigation/side-nav.component.ts b/libs/components/src/navigation/side-nav.component.ts index b13920d9749..35835f1be96 100644 --- a/libs/components/src/navigation/side-nav.component.ts +++ b/libs/components/src/navigation/side-nav.component.ts @@ -1,7 +1,14 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { DragDropModule, CdkDragMove } from "@angular/cdk/drag-drop"; -import { CommonModule } from "@angular/common"; -import { Component, ElementRef, inject, input, viewChild } from "@angular/core"; +import { AsyncPipe } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + input, + viewChild, + inject, +} from "@angular/core"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -12,35 +19,42 @@ import { SideNavService } from "./side-nav.service"; export type SideNavVariant = "primary" | "secondary"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +/** + * Side navigation component that provides a collapsible navigation menu. + */ @Component({ selector: "bit-side-nav", templateUrl: "side-nav.component.html", imports: [ - CommonModule, CdkTrapFocus, NavDividerComponent, BitIconButtonComponent, I18nPipe, DragDropModule, + AsyncPipe, ], host: { class: "tw-block tw-h-full", }, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SideNavComponent { - protected sideNavService = inject(SideNavService); + protected readonly sideNavService = inject(SideNavService); + /** + * Visual variant of the side navigation + * + * @default "primary" + */ readonly variant = input("primary"); private readonly toggleButton = viewChild("toggleButton", { read: ElementRef }); private elementRef = inject>(ElementRef); - protected handleKeyDown = (event: KeyboardEvent) => { + protected readonly handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - this.sideNavService.setClose(); + this.sideNavService.open.set(false); this.toggleButton()?.nativeElement.focus(); return false; } diff --git a/libs/components/src/navigation/side-nav.service.ts b/libs/components/src/navigation/side-nav.service.ts index 63e54c81fe5..05713006a43 100644 --- a/libs/components/src/navigation/side-nav.service.ts +++ b/libs/components/src/navigation/side-nav.service.ts @@ -1,15 +1,6 @@ -import { inject, Injectable } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { - BehaviorSubject, - Observable, - combineLatest, - fromEvent, - map, - startWith, - debounceTime, - first, -} from "rxjs"; +import { computed, effect, inject, Injectable, signal } from "@angular/core"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { BehaviorSubject, Observable, fromEvent, map, startWith, debounceTime, first } from "rxjs"; import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state"; @@ -32,16 +23,17 @@ export class SideNavService { private rootFontSizePx: number; - private _open$ = new BehaviorSubject(isAtOrLargerThanBreakpoint("md")); - open$ = this._open$.asObservable(); + /** + * Whether the side navigation is open or closed. + */ + readonly open = signal(isAtOrLargerThanBreakpoint("md")); private isLargeScreen$ = media(`(min-width: ${BREAKPOINTS.md}px)`); - private _userCollapsePreference$ = new BehaviorSubject(null); - userCollapsePreference$ = this._userCollapsePreference$.asObservable(); + readonly isLargeScreen = toSignal(this.isLargeScreen$, { requireSync: true }); - isOverlay$ = combineLatest([this.open$, this.isLargeScreen$]).pipe( - map(([open, isLargeScreen]) => open && !isLargeScreen), - ); + readonly userCollapsePreference = signal(null); + + readonly isOverlay = computed(() => this.open() && !this.isLargeScreen()); /** * Local component state width @@ -67,16 +59,14 @@ export class SideNavService { this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16"); // Handle open/close state - combineLatest([this.isLargeScreen$, this.userCollapsePreference$]) - .pipe(takeUntilDestroyed()) - .subscribe(([isLargeScreen, userCollapsePreference]) => { - if (!isLargeScreen) { - this.setClose(); - } else if (userCollapsePreference !== "closed") { - // Auto-open when user hasn't set preference (null) or prefers open - this.setOpen(); - } - }); + effect(() => { + if (!this.isLargeScreen()) { + this.open.set(false); + } else if (this.userCollapsePreference() !== "closed") { + // Auto-open when user hasn't set preference (null) or prefers open + this.open.set(true); + } + }); // Initialize the resizable width from state provider this.widthState$.pipe(first()).subscribe((width: number) => { @@ -89,31 +79,14 @@ export class SideNavService { }); } - get open() { - return this._open$.getValue(); - } - - setOpen() { - this._open$.next(true); - } - - setClose() { - this._open$.next(false); - } - /** * Toggle the open/close state of the side nav */ toggle() { - const curr = this._open$.getValue(); // Store user's preference based on what state they're toggling TO - this._userCollapsePreference$.next(curr ? "closed" : "open"); + this.userCollapsePreference.set(this.open() ? "closed" : "open"); - if (curr) { - this.setClose(); - } else { - this.setOpen(); - } + this.open.set(!this.open()); } /** diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index 757859985d6..5aab0d8bec9 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -353,6 +353,19 @@ /* Focus Border */ --color-border-focus: var(--color-black); + + /**======================================== + * SIDENAV BACKGROUND COLORS (Light mode) + * ======================================== */ + --color-sidenav-background: var(--color-brand-800); + --color-sidenav-text: var(--color-white); + --color-sidenav-active-item: var(--color-brand-900); + --color-sidenav-item-hover: var(--color-brand-900); + + --color-admin-sidenav-background: var(--color-gray-100); + --color-admin-sidenav-text: var(--color-gray-900); + --color-admin-sidenav-active-item: var(--color-gray-300); + --color-admin-sidenav-item-hover: var(--color-gray-300); } .theme_light { @@ -542,6 +555,19 @@ /* Focus Border */ --color-border-focus: var(--color-white); + + /**======================================== + * SIDENAV BACKGROUND COLORS (Dark mode) + * ======================================== */ + --color-sidenav-background: var(--color-gray-800); + --color-sidenav-text: var(--color-white); + --color-sidenav-active-item: var(--color-gray-900); + --color-sidenav-item-hover: var(--color-gray-900); + + --color-admin-sidenav-background: var(--color-gray-800); + --color-admin-sidenav-text: var(--color-white); + --color-admin-sidenav-active-item: var(--color-gray-900); + --color-admin-sidenav-item-hover: var(--color-gray-900); } @layer components { diff --git a/libs/components/tailwind.config.base.js b/libs/components/tailwind.config.base.js index bd88f5471ff..d8220c39ff8 100644 --- a/libs/components/tailwind.config.base.js +++ b/libs/components/tailwind.config.base.js @@ -72,11 +72,11 @@ module.exports = { code: rgba("--color-text-code"), }, background: { - DEFAULT: rgba("--color-background"), - alt: rgba("--color-background-alt"), - alt2: rgba("--color-background-alt2"), - alt3: rgba("--color-background-alt3"), - alt4: rgba("--color-background-alt4"), + DEFAULT: "var(--color-bg-primary)", + alt: "var(--color-bg-tertiary)", + alt2: "var(--color-bg-brand)", + alt3: "var(--color-bg-brand-strong)", + alt4: "var(--color-brand-950)", }, bg: { white: "var(--color-bg-white)", @@ -117,6 +117,9 @@ module.exports = { "accent-tertiary": "var(--color-bg-accent-tertiary)", hover: "var(--color-bg-hover)", overlay: "var(--color-bg-overlay)", + sidenav: "var(--color-sidenav-background)", + "sidenav-active-item": "var(--color-sidenav-active-item)", + "sidenav-item-hover": "var(--color-sidenav-item-hover)", }, hover: { default: "var(--color-hover-default)", @@ -159,6 +162,7 @@ module.exports = { "accent-tertiary-soft": "var(--color-fg-accent-tertiary-soft)", "accent-tertiary": "var(--color-fg-accent-tertiary)", "accent-tertiary-strong": "var(--color-fg-accent-tertiary-strong)", + "sidenav-text": "var(--color-sidenav-text)", }, border: { muted: "var(--color-border-muted)", @@ -253,6 +257,7 @@ module.exports = { "fg-accent-tertiary-soft": "var(--color-fg-accent-tertiary-soft)", "fg-accent-tertiary": "var(--color-fg-accent-tertiary)", "fg-accent-tertiary-strong": "var(--color-fg-accent-tertiary-strong)", + "fg-sidenav-text": "var(--color-sidenav-text)", }), borderColor: ({ theme }) => ({ ...theme("colors"), diff --git a/libs/importer/src/components/chrome/import-chrome.component.ts b/libs/importer/src/components/chrome/import-chrome.component.ts index 5467b08ee61..420a95bca5e 100644 --- a/libs/importer/src/components/chrome/import-chrome.component.ts +++ b/libs/importer/src/components/chrome/import-chrome.component.ts @@ -195,6 +195,8 @@ export class ImportChromeComponent implements OnInit, OnDestroy { return "Brave"; } else if (format === "vivaldicsv") { return "Vivaldi"; + } else if (format === "arccsv") { + return "Arc"; } return "Chrome"; } diff --git a/libs/importer/src/importers/arc-csv-importer.spec.ts b/libs/importer/src/importers/arc-csv-importer.spec.ts new file mode 100644 index 00000000000..3ecd3de02bf --- /dev/null +++ b/libs/importer/src/importers/arc-csv-importer.spec.ts @@ -0,0 +1,139 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; + +import { ArcCsvImporter } from "./arc-csv-importer"; +import { data as missingNameAndUrlData } from "./spec-data/arc-csv/missing-name-and-url-data.csv"; +import { data as missingNameWithUrlData } from "./spec-data/arc-csv/missing-name-with-url-data.csv"; +import { data as passwordWithNoteData } from "./spec-data/arc-csv/password-with-note-data.csv"; +import { data as simplePasswordData } from "./spec-data/arc-csv/simple-password-data.csv"; +import { data as subdomainData } from "./spec-data/arc-csv/subdomain-data.csv"; +import { data as urlWithWwwData } from "./spec-data/arc-csv/url-with-www-data.csv"; + +const CipherData = [ + { + title: "should parse password", + csv: simplePasswordData, + expected: Object.assign(new CipherView(), { + name: "example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://example.com/", + }), + ], + }), + notes: null, + type: 1, + }), + }, + { + title: "should parse password with note", + csv: passwordWithNoteData, + expected: Object.assign(new CipherView(), { + name: "example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://example.com/", + }), + ], + }), + notes: "This is a test note", + type: 1, + }), + }, + { + title: "should strip www. prefix from name", + csv: urlWithWwwData, + expected: Object.assign(new CipherView(), { + name: "example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://www.example.com/", + }), + ], + }), + notes: null, + type: 1, + }), + }, + { + title: "should extract name from URL when name is missing", + csv: missingNameWithUrlData, + expected: Object.assign(new CipherView(), { + name: "example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://example.com/login", + }), + ], + }), + notes: null, + type: 1, + }), + }, + { + title: "should use -- as name when both name and URL are missing", + csv: missingNameAndUrlData, + expected: Object.assign(new CipherView(), { + name: "--", + login: Object.assign(new LoginView(), { + username: null, + password: "password123", + uris: null, + }), + notes: null, + type: 1, + }), + }, + { + title: "should preserve subdomain in name", + csv: subdomainData, + expected: Object.assign(new CipherView(), { + name: "login.example.com", + login: Object.assign(new LoginView(), { + username: "user@example.com", + password: "password123", + uris: [ + Object.assign(new LoginUriView(), { + uri: "https://login.example.com/auth", + }), + ], + }), + notes: null, + type: 1, + }), + }, +]; + +describe("Arc CSV Importer", () => { + CipherData.forEach((data) => { + it(data.title, async () => { + jest.useFakeTimers().setSystemTime(data.expected.creationDate); + const importer = new ArcCsvImporter(); + const result = await importer.parse(data.csv); + expect(result != null).toBe(true); + expect(result.ciphers.length).toBeGreaterThan(0); + + const cipher = result.ciphers.shift(); + let property: keyof typeof data.expected; + for (property in data.expected) { + if (Object.prototype.hasOwnProperty.call(data.expected, property)) { + expect(Object.prototype.hasOwnProperty.call(cipher, property)).toBe(true); + expect(cipher[property]).toEqual(data.expected[property]); + } + } + }); + }); +}); diff --git a/libs/importer/src/importers/arc-csv-importer.ts b/libs/importer/src/importers/arc-csv-importer.ts new file mode 100644 index 00000000000..eb262717009 --- /dev/null +++ b/libs/importer/src/importers/arc-csv-importer.ts @@ -0,0 +1,30 @@ +import { ImportResult } from "../models/import-result"; + +import { BaseImporter } from "./base-importer"; +import { Importer } from "./importer"; + +export class ArcCsvImporter extends BaseImporter implements Importer { + parse(data: string): Promise { + const result = new ImportResult(); + const results = this.parseCsv(data, true); + if (results == null) { + result.success = false; + return Promise.resolve(result); + } + + results.forEach((value) => { + const cipher = this.initLoginCipher(); + const url = this.getValueOrDefault(value.url); + cipher.name = this.getValueOrDefault(this.nameFromUrl(url) ?? "", "--"); + cipher.login.username = this.getValueOrDefault(value.username); + cipher.login.password = this.getValueOrDefault(value.password); + cipher.login.uris = this.makeUriArray(value.url); + cipher.notes = this.getValueOrDefault(value.note); + this.cleanupCipher(cipher); + result.ciphers.push(cipher); + }); + + result.success = true; + return Promise.resolve(result); + } +} diff --git a/libs/importer/src/importers/index.ts b/libs/importer/src/importers/index.ts index a1e2c46868f..8f9eb923180 100644 --- a/libs/importer/src/importers/index.ts +++ b/libs/importer/src/importers/index.ts @@ -1,3 +1,4 @@ +export { ArcCsvImporter } from "./arc-csv-importer"; export { AscendoCsvImporter } from "./ascendo-csv-importer"; export { AvastCsvImporter, AvastJsonImporter } from "./avast"; export { AviraCsvImporter } from "./avira-csv-importer"; diff --git a/libs/importer/src/importers/spec-data/arc-csv/missing-name-and-url-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/missing-name-and-url-data.csv.ts new file mode 100644 index 00000000000..74170cb57bf --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/missing-name-and-url-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +,,,password123,`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/missing-name-with-url-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/missing-name-with-url-data.csv.ts new file mode 100644 index 00000000000..f0271009099 --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/missing-name-with-url-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +,https://example.com/login,user@example.com,password123,`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/password-with-note-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/password-with-note-data.csv.ts new file mode 100644 index 00000000000..bf9d218f01b --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/password-with-note-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +example.com,https://example.com/,user@example.com,password123,This is a test note`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/simple-password-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/simple-password-data.csv.ts new file mode 100644 index 00000000000..695b9d10785 --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/simple-password-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +example.com,https://example.com/,user@example.com,password123,`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/subdomain-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/subdomain-data.csv.ts new file mode 100644 index 00000000000..bde4c3282af --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/subdomain-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +login.example.com,https://login.example.com/auth,user@example.com,password123,`; diff --git a/libs/importer/src/importers/spec-data/arc-csv/url-with-www-data.csv.ts b/libs/importer/src/importers/spec-data/arc-csv/url-with-www-data.csv.ts new file mode 100644 index 00000000000..17bb1f587e6 --- /dev/null +++ b/libs/importer/src/importers/spec-data/arc-csv/url-with-www-data.csv.ts @@ -0,0 +1,2 @@ +export const data = `name,url,username,password,note +www.example.com,https://www.example.com/,user@example.com,password123,`; diff --git a/libs/importer/src/models/import-options.ts b/libs/importer/src/models/import-options.ts index 22a4f63b248..a4c77483be6 100644 --- a/libs/importer/src/models/import-options.ts +++ b/libs/importer/src/models/import-options.ts @@ -46,6 +46,7 @@ export const regularImportOptions = [ { id: "ascendocsv", name: "Ascendo DataVault (csv)" }, { id: "meldiumcsv", name: "Meldium (csv)" }, { id: "passkeepcsv", name: "PassKeep (csv)" }, + { id: "arccsv", name: "Arc" }, { id: "edgecsv", name: "Edge" }, { id: "operacsv", name: "Opera" }, { id: "vivaldicsv", name: "Vivaldi" }, diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index bd0a9eb0d4d..38c399eb200 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -31,6 +31,7 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res import { KeyService } from "@bitwarden/key-management"; import { + ArcCsvImporter, AscendoCsvImporter, AvastCsvImporter, AvastJsonImporter, @@ -256,6 +257,8 @@ export class ImportService implements ImportServiceAbstraction { return new PadlockCsvImporter(); case "keepass2xml": return new KeePass2XmlImporter(); + case "arccsv": + return new ArcCsvImporter(); case "edgecsv": case "chromecsv": case "operacsv": diff --git a/libs/key-management-ui/src/index.ts b/libs/key-management-ui/src/index.ts index b273b49cb73..7b9d5a629ac 100644 --- a/libs/key-management-ui/src/index.ts +++ b/libs/key-management-ui/src/index.ts @@ -4,6 +4,8 @@ export { LockComponent } from "./lock/components/lock.component"; export { LockComponentService, UnlockOptions } from "./lock/services/lock-component.service"; +export { WebAuthnPrfUnlockService } from "./lock/services/webauthn-prf-unlock.service"; +export { DefaultWebAuthnPrfUnlockService } from "./lock/services/default-webauthn-prf-unlock.service"; export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust-info.component"; export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component"; export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component"; diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index c1577b76a4d..a93464b265c 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -49,6 +49,8 @@
+ + @@ -113,6 +115,11 @@
+ + @@ -127,6 +134,7 @@ [unlockOptions]="unlockOptions" [biometricUnlockBtnText]="biometricUnlockBtnText" (successfulUnlock)="successfulMasterPasswordUnlock($event)" + (prfUnlockSuccess)="onPrfUnlockSuccess($event)" (logOut)="logOut()" > } diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 054212f8851..47c4d14fc98 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -51,6 +51,7 @@ import { UnlockOptionValue, UnlockOptions, } from "../services/lock-component.service"; +import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service"; import { LockComponent } from "./lock.component"; @@ -84,6 +85,7 @@ describe("LockComponent", () => { const mockLockComponentService = mock(); const mockAnonLayoutWrapperDataService = mock(); const mockBroadcasterService = mock(); + const mockWebAuthnPrfUnlockService = mock(); const mockEncryptedMigrator = mock(); const mockActivatedRoute = { snapshot: { @@ -149,6 +151,7 @@ describe("LockComponent", () => { { provide: LockComponentService, useValue: mockLockComponentService }, { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, { provide: BroadcasterService, useValue: mockBroadcasterService }, + { provide: WebAuthnPrfUnlockService, useValue: mockWebAuthnPrfUnlockService }, { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: EncryptedMigrator, useValue: mockEncryptedMigrator }, ], diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 03ab6033441..6057fe06456 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -60,6 +60,7 @@ import { } from "../services/lock-component.service"; import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component"; +import { UnlockViaPrfComponent } from "./unlock-via-prf.component"; const BroadcasterSubscriptionId = "LockComponent"; @@ -98,6 +99,7 @@ const BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES = [ FormFieldModule, AsyncActionsModule, IconButtonModule, + UnlockViaPrfComponent, MasterPasswordLockComponent, TooltipDirective, ], @@ -460,6 +462,14 @@ export class LockComponent implements OnInit, OnDestroy { } } + async onPrfUnlockSuccess(userKey: UserKey): Promise { + await this.setUserKeyAndContinue(userKey); + } + + togglePassword() { + this.showPassword = !this.showPassword; + } + private validatePin(): boolean { if (this.formGroup?.invalid) { this.toastService.showToast({ diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html index 4c7cdd48353..878915ec6ff 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html @@ -54,6 +54,11 @@ } + + diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts index dabab3e558a..6d0da1033b7 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts @@ -18,6 +18,7 @@ import { UserKey } from "@bitwarden/common/types/key"; import { AsyncActionsModule, ButtonModule, + DialogService, FormFieldModule, IconButtonModule, ToastService, @@ -27,6 +28,7 @@ import { CommandDefinition, MessageListener } from "@bitwarden/messaging"; import { UserId } from "@bitwarden/user-core"; import { UnlockOption, UnlockOptions } from "../../services/lock-component.service"; +import { WebAuthnPrfUnlockService } from "../../services/webauthn-prf-unlock.service"; import { MasterPasswordLockComponent } from "./master-password-lock.component"; @@ -41,6 +43,8 @@ describe("MasterPasswordLockComponent", () => { const logService = mock(); const platformUtilsService = mock(); const messageListener = mock(); + const webAuthnPrfUnlockService = mock(); + const dialogService = mock(); const mockMasterPassword = "testExample"; const activeAccount: Account = { @@ -64,6 +68,7 @@ describe("MasterPasswordLockComponent", () => { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledLocally, }, + prf: { enabled: false }, }; accountService.activeAccount$ = of(account); @@ -110,6 +115,8 @@ describe("MasterPasswordLockComponent", () => { { provide: LogService, useValue: logService }, { provide: PlatformUtilsService, useValue: platformUtilsService }, { provide: MessageListener, useValue: messageListener }, + { provide: WebAuthnPrfUnlockService, useValue: webAuthnPrfUnlockService }, + { provide: DialogService, useValue: dialogService }, ], }).compileComponents(); diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts index 1237869717f..5229effd366 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts @@ -36,6 +36,7 @@ import { UnlockOptions, UnlockOptionValue, } from "../../services/lock-component.service"; +import { UnlockViaPrfComponent } from "../unlock-via-prf.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -49,6 +50,7 @@ import { FormFieldModule, AsyncActionsModule, IconButtonModule, + UnlockViaPrfComponent, ], }) export class MasterPasswordLockComponent implements OnInit, OnDestroy { @@ -76,6 +78,7 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy { }); successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>(); + prfUnlockSuccess = output(); logOut = output(); protected showPassword = false; @@ -143,4 +146,8 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy { }); } } + + onPrfUnlockSuccess(userKey: UserKey): void { + this.prfUnlockSuccess.emit(userKey); + } } diff --git a/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts b/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts new file mode 100644 index 00000000000..7a0b99b232d --- /dev/null +++ b/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts @@ -0,0 +1,114 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit, input, output } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; +import { AsyncActionsModule, ButtonModule, DialogService } from "@bitwarden/components"; + +import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service"; + +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "bit-unlock-via-prf", + standalone: true, + imports: [CommonModule, JslibModule, ButtonModule, AsyncActionsModule], + template: ` + @if (isAvailable) { + @if (formButton()) { + + } + @if (!formButton()) { + + } + } + `, +}) +export class UnlockViaPrfComponent implements OnInit { + readonly formButton = input(false); + readonly unlockSuccess = output(); + + unlocking = false; + isAvailable = false; + private userId: UserId | null = null; + + constructor( + private accountService: AccountService, + private webAuthnPrfUnlockService: WebAuthnPrfUnlockService, + private dialogService: DialogService, + private i18nService: I18nService, + private logService: LogService, + ) {} + + async ngOnInit(): Promise { + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + if (activeAccount?.id) { + this.userId = activeAccount.id; + this.isAvailable = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(this.userId); + } + } + + async unlockViaPrf(): Promise { + if (!this.userId || !this.isAvailable) { + return; + } + + this.unlocking = true; + + try { + const userKey = await this.webAuthnPrfUnlockService.unlockVaultWithPrf(this.userId); + this.unlockSuccess.emit(userKey); + } catch (error) { + this.logService.error("[UnlockViaPrfComponent] Failed to unlock via PRF:", error); + + let errorMessage = this.i18nService.t("unexpectedError"); + + // Handle specific PRF error cases + if (error instanceof Error) { + if (error.message.includes("No PRF credentials")) { + errorMessage = this.i18nService.t("noPrfCredentialsAvailable"); + } else if (error.message.includes("canceled")) { + // User canceled the operation, don't show error + this.unlocking = false; + return; + } + } + + await this.dialogService.openSimpleDialog({ + title: { key: "error" }, + content: errorMessage, + acceptButtonText: { key: "ok" }, + type: "danger", + }); + } finally { + this.unlocking = false; + } + } +} diff --git a/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts b/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts new file mode 100644 index 00000000000..960a663b589 --- /dev/null +++ b/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts @@ -0,0 +1,288 @@ +import { firstValueFrom } from "rxjs"; + +import { + UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, + WebAuthnPrfUserDecryptionOption, +} from "@bitwarden/auth/common"; +import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; +import { ClientType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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 { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils"; +import { UserId } from "@bitwarden/common/types/guid"; +import { PrfKey, UserKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { WebAuthnPrfUnlockService } from "./webauthn-prf-unlock.service"; + +export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService { + private navigatorCredentials: CredentialsContainer; + + constructor( + private webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction, + private keyService: KeyService, + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private encryptService: EncryptService, + private environmentService: EnvironmentService, + private platformUtilsService: PlatformUtilsService, + private window: Window, + private logService: LogService, + private configService: ConfigService, + ) { + this.navigatorCredentials = this.window.navigator.credentials; + } + + async isPrfUnlockAvailable(userId: UserId): Promise { + try { + // Check if feature flag is enabled + const passkeyUnlockEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PasskeyUnlock, + ); + if (!passkeyUnlockEnabled) { + return false; + } + + // Check if browser supports WebAuthn + if (!this.navigatorCredentials || !this.navigatorCredentials.get) { + return false; + } + + // If we're in the browser extension, check if we're in a Chromium browser + if ( + this.platformUtilsService.getClientType() === ClientType.Browser && + !this.platformUtilsService.isChromium() + ) { + return false; + } + + // Check if user has any WebAuthn PRF credentials registered + const credentials = await this.getPrfUnlockCredentials(userId); + if (credentials.length === 0) { + return false; + } + + return true; + } catch (error) { + this.logService.error("Error checking PRF unlock availability:", error); + return false; + } + } + + private async getPrfUnlockCredentials( + userId: UserId, + ): Promise<{ credentialId: string; transports: string[] }[]> { + try { + const userDecryptionOptions = await this.getUserDecryptionOptions(userId); + if (!userDecryptionOptions?.webAuthnPrfOptions) { + return []; + } + return userDecryptionOptions.webAuthnPrfOptions.map((option) => ({ + credentialId: option.credentialId, + transports: option.transports, + })); + } catch (error) { + this.logService.error("Error getting PRF unlock credentials:", error); + return []; + } + } + + /** + * Unlocks the vault using WebAuthn PRF. + * + * @param userId The user ID to unlock vault for + * @returns Promise the decrypted user key + * @throws Error if unlock fails for any reason + */ + async unlockVaultWithPrf(userId: UserId): Promise { + // Get offline PRF credentials from user decryption options + const credentials = await this.getPrfUnlockCredentials(userId); + if (credentials.length === 0) { + throw new Error("No PRF credentials available for unlock"); + } + + const response = await this.performWebAuthnGetWithPrf(credentials, userId); + const prfKey = await this.createPrfKeyFromResponse(response); + const prfOption = await this.getPrfOptionForCredential(response.id, userId); + + // PRF unlock follows the same key derivation process as PRF login: + // PRF key → decrypt private key → use private key to decrypt user key + + // Step 1: Decrypt PRF encrypted private key using the PRF key + const privateKey = await this.encryptService.unwrapDecapsulationKey( + new EncString(prfOption.encryptedPrivateKey), + prfKey, + ); + + // Step 2: Use private key to decrypt user key + const userKey = await this.encryptService.decapsulateKeyUnsigned( + new EncString(prfOption.encryptedUserKey), + privateKey, + ); + + if (!userKey) { + throw new Error("Failed to decrypt user key from private key"); + } + + return userKey as UserKey; + } + + /** + * Performs WebAuthn get operation with PRF extension. + * + * @param credentials Available PRF credentials for the user + * @returns PublicKeyCredential response from the authenticator + * @throws Error if WebAuthn operation fails or returns invalid response + */ + private async performWebAuthnGetWithPrf( + credentials: { credentialId: string; transports: string[] }[], + userId: UserId, + ): Promise { + const rpId = await this.getRpIdForUser(userId); + const prfSalt = await this.getUnlockWithPrfSalt(); + + const options: CredentialRequestOptions = { + publicKey: { + challenge: new Uint8Array(32), + allowCredentials: credentials.map(({ credentialId, transports }) => { + // The credential ID is already base64url encoded from login storage + // We need to decode it to ArrayBuffer for WebAuthn + const decodedId = Fido2Utils.stringToBuffer(credentialId); + return { + type: "public-key", + id: decodedId, + transports: (transports || []) as AuthenticatorTransport[], + }; + }), + rpId, + userVerification: "preferred", // Allow platform authenticators to work properly + extensions: { + prf: { eval: { first: prfSalt } }, + } as any, + }, + }; + + const response = await this.navigatorCredentials.get(options); + + if (!response) { + throw new Error("WebAuthn get() returned null/undefined"); + } + + if (!(response instanceof PublicKeyCredential)) { + throw new Error("Failed to get PRF credential for unlock"); + } + + return response; + } + + /** + * Extracts PRF result from WebAuthn response and creates a PrfKey. + * + * @param response PublicKeyCredential response from authenticator + * @returns PrfKey derived from the PRF extension output + * @throws Error if no PRF result is present in the response + */ + private async createPrfKeyFromResponse(response: PublicKeyCredential): Promise { + // Extract PRF result + // TODO: Remove `any` when typescript typings add support for PRF + const extensionResults = response.getClientExtensionResults() as any; + const prfResult = extensionResults.prf?.results?.first; + if (!prfResult) { + throw new Error("No PRF result received from authenticator"); + } + + try { + return await this.webAuthnLoginPrfKeyService.createSymmetricKeyFromPrf(prfResult); + } catch (error) { + this.logService.error("Failed to create unlock key from PRF:", error); + throw error; + } + } + + /** + * Gets the WebAuthn PRF option that matches the credential used in the response. + * + * @param credentialId Credential ID to match + * @param userId User ID to get decryption options for + * @returns Matching WebAuthnPrfUserDecryptionOption with encrypted keys + * @throws Error if no PRF options exist or no matching option is found + */ + private async getPrfOptionForCredential( + credentialId: string, + userId: UserId, + ): Promise { + const userDecryptionOptions = await this.getUserDecryptionOptions(userId); + + if ( + !userDecryptionOptions?.webAuthnPrfOptions || + userDecryptionOptions.webAuthnPrfOptions.length === 0 + ) { + throw new Error("No WebAuthn PRF option found for user - cannot perform PRF unlock"); + } + + const prfOption = userDecryptionOptions.webAuthnPrfOptions.find( + (option) => option.credentialId === credentialId, + ); + + if (!prfOption) { + throw new Error("No matching WebAuthn PRF option found for this credential"); + } + + return prfOption; + } + + private async getUnlockWithPrfSalt(): Promise { + try { + // Use the same salt as login to ensure PRF keys match + return await this.webAuthnLoginPrfKeyService.getLoginWithPrfSalt(); + } catch (error) { + this.logService.error("Error getting unlock PRF salt:", error); + throw error; + } + } + + /** + * Helper method to get user decryption options for a user + */ + private async getUserDecryptionOptions(userId: UserId): Promise { + try { + return (await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + )) as UserDecryptionOptions; + } catch (error) { + this.logService.error("Error getting user decryption options:", error); + return null; + } + } + + /** + * Helper method to get the appropriate rpId for WebAuthn PRF operations + * Returns the hostname from the user's environment configuration + */ + private async getRpIdForUser(userId: UserId): Promise { + try { + const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId)); + const hostname = environment.getHostname(); + + // The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host. + if (!hostname) { + return undefined; + } + + // Extract hostname using URL parsing to handle IPv6 and ports correctly + // This removes ports etc. + const url = new URL(`https://${hostname}`); + const rpId = url.hostname; + + return rpId; + } catch (error) { + this.logService.error("Error getting rpId", error); + return undefined; + } + } +} diff --git a/libs/key-management-ui/src/lock/services/lock-component.service.ts b/libs/key-management-ui/src/lock/services/lock-component.service.ts index 0fc25ca7dfb..53cb256f251 100644 --- a/libs/key-management-ui/src/lock/services/lock-component.service.ts +++ b/libs/key-management-ui/src/lock/services/lock-component.service.ts @@ -10,6 +10,7 @@ export const UnlockOption = Object.freeze({ MasterPassword: "masterPassword", Pin: "pin", Biometrics: "biometrics", + Prf: "prf", }) satisfies { [Prop in keyof UnlockOptions as Capitalize]: Prop }; export type UnlockOptions = { @@ -23,6 +24,9 @@ export type UnlockOptions = { enabled: boolean; biometricsStatus: BiometricsStatus; }; + prf: { + enabled: boolean; + }; }; /** diff --git a/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts b/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts new file mode 100644 index 00000000000..f0b02a0ed3f --- /dev/null +++ b/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts @@ -0,0 +1,27 @@ +import { UserKey } from "@bitwarden/common/types/key"; +import { UserId } from "@bitwarden/user-core"; + +/** + * Service for unlocking vault using WebAuthn PRF. + * Provides offline vault unlock capabilities by deriving unlock keys from PRF outputs. + */ +export abstract class WebAuthnPrfUnlockService { + /** + * Check if PRF unlock is available for the current user + * @param userId The user ID to check PRF unlock availability for + * @returns Promise true if PRF unlock is available + */ + abstract isPrfUnlockAvailable(userId: UserId): Promise; + + /** + * Attempt to unlock the vault using WebAuthn PRF + * @param userId The user ID to unlock vault for + * @returns Promise the decrypted user key + * @throws Error if no PRF credentials are available + * @throws Error if the authenticator returns no PRF result + * @throws Error if the user cancels the WebAuthn operation + * @throws Error if decryption of the user key fails + * @throws Error if no matching PRF option is found for the credential + */ + abstract unlockVaultWithPrf(userId: UserId): Promise; +} diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 9d96d7c09b1..85129aaedf4 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take } from "rxjs"; +import { ClientType } from "@bitwarden/client-type"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; @@ -259,7 +260,18 @@ describe("keyService", () => { }); }); - it("clears the Auto key if vault timeout is set to anything other than null", async () => { + it("sets an Auto key if vault timeout is set to 10 minutes and is Cli", async () => { + await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId); + platformUtilService.getClientType.mockReturnValue(ClientType.Cli); + + await keyService.setUserKey(mockUserKey, mockUserId); + + expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(mockUserKey.keyB64, { + userId: mockUserId, + }); + }); + + it("clears the Auto key if vault timeout is set to 10 minutes", async () => { await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId); await keyService.setUserKey(mockUserKey, mockUserId); diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 4c749e9f6c4..d0b68229ea9 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -14,6 +14,7 @@ import { switchMap, } from "rxjs"; +import { ClientType } from "@bitwarden/client-type"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; import { BaseEncryptedOrganizationKey } from "@bitwarden/common/admin-console/models/domain/encrypted-organization-key"; import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response"; @@ -671,9 +672,13 @@ export class DefaultKeyService implements KeyServiceAbstraction { } protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId: UserId) { - let shouldStoreKey = false; switch (keySuffix) { case KeySuffixOptions.Auto: { + // Cli has fixed Never vault timeout, and it should not be affected by a policy. + if (this.platformUtilService.getClientType() == ClientType.Cli) { + return true; + } + // TODO: Sharing the UserKeyDefinition is temporary to get around a circ dep issue between // the VaultTimeoutSettingsSvc and this service. // This should be fixed as part of the PM-7082 - Auto Key Service work. @@ -683,11 +688,14 @@ export class DefaultKeyService implements KeyServiceAbstraction { .pipe(filter((timeout) => timeout != null)), ); - shouldStoreKey = vaultTimeout == VaultTimeoutStringType.Never; - break; + this.logService.debug( + `[KeyService] Should store auto key for vault timeout ${vaultTimeout}`, + ); + + return vaultTimeout == VaultTimeoutStringType.Never; } } - return shouldStoreKey; + return false; } protected async getKeyFromStorage( diff --git a/libs/vault/src/cipher-view/cipher-view.component.spec.ts b/libs/vault/src/cipher-view/cipher-view.component.spec.ts index 18a5132781b..2300565035e 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.spec.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.spec.ts @@ -8,7 +8,6 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -42,11 +41,9 @@ describe("CipherViewComponent", () => { let mockLogService: LogService; let mockCipherRiskService: CipherRiskService; let mockBillingAccountProfileStateService: BillingAccountProfileStateService; - let mockConfigService: ConfigService; // Mock data let mockCipherView: CipherView; - let featureFlagEnabled$: BehaviorSubject; let hasPremiumFromAnySource$: BehaviorSubject; let activeAccount$: BehaviorSubject; @@ -57,7 +54,6 @@ describe("CipherViewComponent", () => { email: "test@example.com", } as Account); - featureFlagEnabled$ = new BehaviorSubject(false); hasPremiumFromAnySource$ = new BehaviorSubject(true); // Create service mocks @@ -83,9 +79,6 @@ describe("CipherViewComponent", () => { .fn() .mockReturnValue(hasPremiumFromAnySource$); - mockConfigService = mock(); - mockConfigService.getFeatureFlag$ = jest.fn().mockReturnValue(featureFlagEnabled$); - // Setup mock cipher view mockCipherView = new CipherView(); mockCipherView.id = "cipher-id"; @@ -110,7 +103,6 @@ describe("CipherViewComponent", () => { provide: BillingAccountProfileStateService, useValue: mockBillingAccountProfileStateService, }, - { provide: ConfigService, useValue: mockConfigService }, ], schemas: [NO_ERRORS_SCHEMA], }) @@ -145,7 +137,6 @@ describe("CipherViewComponent", () => { beforeEach(() => { // Reset observables to default values for this test suite - featureFlagEnabled$.next(true); hasPremiumFromAnySource$.next(true); // Setup default mock for computeCipherRiskForUser (individual tests can override) @@ -162,18 +153,6 @@ describe("CipherViewComponent", () => { component = fixture.componentInstance; }); - it("returns false when feature flag is disabled", fakeAsync(() => { - featureFlagEnabled$.next(false); - - const cipher = createLoginCipherView(); - fixture.componentRef.setInput("cipher", cipher); - fixture.detectChanges(); - tick(); - - expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled(); - expect(component.passwordIsAtRisk()).toBe(false); - })); - it("returns false when cipher has no login password", fakeAsync(() => { const cipher = createLoginCipherView(); cipher.login = {} as any; // No password diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index b5c063df51e..26e3f18b542 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -13,8 +13,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { isCardExpired } from "@bitwarden/common/autofill/utils"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getByIds } from "@bitwarden/common/platform/misc"; @@ -113,7 +111,6 @@ export class CipherViewComponent { private logService: LogService, private cipherRiskService: CipherRiskService, private billingAccountService: BillingAccountProfileStateService, - private configService: ConfigService, ) {} readonly resolvedCollections = toSignal( @@ -248,19 +245,9 @@ export class CipherViewComponent { * The password is only evaluated when the user is premium and has edit access to the cipher. */ readonly passwordIsAtRisk = toSignal( - combineLatest([ - this.activeUserId$, - this.cipher$, - this.configService.getFeatureFlag$(FeatureFlag.RiskInsightsForPremium), - ]).pipe( - switchMap(([userId, cipher, featureEnabled]) => { - if ( - !featureEnabled || - !cipher.hasLoginPassword || - !cipher.edit || - cipher.organizationId || - cipher.isDeleted - ) { + combineLatest([this.activeUserId$, this.cipher$]).pipe( + switchMap(([userId, cipher]) => { + if (!cipher.hasLoginPassword || !cipher.edit || cipher.organizationId || cipher.isDeleted) { return of(false); } return this.switchPremium$( diff --git a/libs/vault/src/components/carousel/carousel.component.spec.ts b/libs/vault/src/components/carousel/carousel.component.spec.ts index abbfe963ddf..eb9480398e9 100644 --- a/libs/vault/src/components/carousel/carousel.component.spec.ts +++ b/libs/vault/src/components/carousel/carousel.component.spec.ts @@ -1,4 +1,4 @@ -import { Component } from "@angular/core"; +import { Component, ChangeDetectionStrategy } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; @@ -7,11 +7,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component"; import { VaultCarouselComponent } from "./carousel.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-test-carousel-slide", imports: [VaultCarouselComponent, VaultCarouselSlideComponent], + changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -93,8 +92,7 @@ describe("VaultCarouselComponent", () => { const backButton = fixture.debugElement.queryAll(By.css("button"))[0]; middleSlideButton.nativeElement.click(); - await new Promise((r) => setTimeout(r, 100)); // Give time for the DOM to update. - + fixture.detectChanges(); jest.spyOn(component.slideChange, "emit"); backButton.nativeElement.click(); diff --git a/libs/vault/src/components/carousel/carousel.component.ts b/libs/vault/src/components/carousel/carousel.component.ts index 4e180f09f9b..c622f2e5d85 100644 --- a/libs/vault/src/components/carousel/carousel.component.ts +++ b/libs/vault/src/components/carousel/carousel.component.ts @@ -22,7 +22,6 @@ import { take } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ButtonModule, IconButtonModule } from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button.component"; import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component"; @@ -41,7 +40,6 @@ import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.com ButtonModule, VaultCarouselContentComponent, VaultCarouselButtonComponent, - I18nPipe, ], }) export class VaultCarouselComponent implements AfterViewInit { diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.html b/libs/vault/src/components/download-attachment/download-attachment.component.html index 9d80f36818a..c6665c5d569 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.html +++ b/libs/vault/src/components/download-attachment/download-attachment.component.html @@ -5,6 +5,6 @@ buttonType="main" size="small" type="button" - [label]="'downloadAttachmentName' | i18n: attachment().fileName" + [label]="'downloadAttachmentLabel' | i18n" > } diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts index 3bbc375fdfc..a46ce28fca8 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts @@ -108,7 +108,7 @@ describe("DownloadAttachmentComponent", () => { it("renders delete button", () => { const deleteButton = fixture.debugElement.query(By.css("button")); - expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentName"); + expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentLabel"); }); describe("download attachment", () => { diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html index 00cfa701529..268f5b912d1 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html @@ -8,7 +8,7 @@ id="newItemDropdown" [appA11yTitle]="'new' | i18n" > - + {{ "new" | i18n }} diff --git a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts index 51154c3cee9..f5da99cae61 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts @@ -16,6 +16,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -43,6 +44,7 @@ describe("DefaultVaultItemsTransferService", () => { let mockEventCollectionService: MockProxy; let mockConfigService: MockProxy; let mockOrganizationUserApiService: MockProxy; + let mockSyncService: MockProxy; const userId = "user-id" as UserId; const organizationId = "org-id" as OrganizationId; @@ -79,6 +81,7 @@ describe("DefaultVaultItemsTransferService", () => { mockEventCollectionService = mock(); mockConfigService = mock(); mockOrganizationUserApiService = mock(); + mockSyncService = mock(); mockI18nService.t.mockImplementation((key) => key); transferInProgressValues = []; @@ -95,6 +98,7 @@ describe("DefaultVaultItemsTransferService", () => { mockEventCollectionService, mockConfigService, mockOrganizationUserApiService, + mockSyncService, ); }); @@ -557,6 +561,8 @@ describe("DefaultVaultItemsTransferService", () => { mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? [])); mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? [])); mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection)); + mockSyncService.fullSync.mockResolvedValue(true); + mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined); } it("does nothing when feature flag is disabled", async () => { @@ -635,11 +641,11 @@ describe("DefaultVaultItemsTransferService", () => { mockDialogService.open .mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined)) .mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed)); - mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined); await service.enforceOrganizationDataOwnership(userId); expect(mockOrganizationUserApiService.revokeSelf).toHaveBeenCalledWith(organizationId); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); expect(mockToastService.showToast).toHaveBeenCalledWith({ variant: "success", message: "leftOrganization", diff --git a/libs/vault/src/services/default-vault-items-transfer.service.ts b/libs/vault/src/services/default-vault-items-transfer.service.ts index 6009fc97e7c..3e65d3157f5 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.ts @@ -23,6 +23,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { getById } from "@bitwarden/common/platform/misc"; import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -54,6 +55,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi private eventCollectionService: EventCollectionService, private configService: ConfigService, private organizationUserApiService: OrganizationUserApiService, + private syncService: SyncService, ) {} private _transferInProgressSubject = new BehaviorSubject(false); @@ -164,7 +166,6 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi if (!userAcceptedTransfer) { await this.organizationUserApiService.revokeSelf(migrationInfo.enforcingOrganization.id); - this.toastService.showToast({ variant: "success", message: this.i18nService.t("leftOrganization"), @@ -176,6 +177,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi undefined, migrationInfo.enforcingOrganization.id, ); + // Sync to reflect organization removal + await this.syncService.fullSync(true); return; } diff --git a/package-lock.json b/package-lock.json index ff632dc2807..2cd18e11adc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,7 +52,7 @@ "lunr": "2.3.9", "multer": "2.0.2", "ngx-toastr": "19.1.0", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "oidc-client-ts": "2.4.1", "open": "11.0.0", @@ -110,7 +110,7 @@ "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", "@types/node": "22.19.3", - "@types/node-fetch": "2.6.4", + "@types/node-fetch": "2.6.13", "@types/node-forge": "1.3.14", "@types/papaparse": "5.5.0", "@types/proper-lockfile": "4.1.4", @@ -192,11 +192,11 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2025.12.1" + "version": "2026.1.0" }, "apps/cli": { "name": "@bitwarden/cli", - "version": "2025.12.1", + "version": "2026.1.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@koa/multer": "4.0.0", @@ -217,7 +217,7 @@ "lowdb": "1.0.0", "lunr": "2.3.9", "multer": "2.0.2", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "open": "11.0.0", "papaparse": "5.5.3", @@ -278,7 +278,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.12.1", + "version": "2026.1.0", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -491,7 +491,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2026.1.0" + "version": "2026.1.1" }, "libs/admin-console": { "name": "@bitwarden/admin-console", @@ -15842,53 +15842,14 @@ } }, "node_modules/@types/node-fetch": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz", - "integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==", + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "form-data": "^3.0.0" - } - }, - "node_modules/@types/node-fetch/node_modules/form-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.3.tgz", - "integrity": "sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@types/node-fetch/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@types/node-fetch/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" + "form-data": "^4.0.4" } }, "node_modules/@types/node-forge": { @@ -32816,9 +32777,9 @@ } }, "node_modules/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" diff --git a/package.json b/package.json index 829dc91370a..8455d97c87c 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", "@types/node": "22.19.3", - "@types/node-fetch": "2.6.4", + "@types/node-fetch": "2.6.13", "@types/node-forge": "1.3.14", "@types/papaparse": "5.5.0", "@types/proper-lockfile": "4.1.4", @@ -191,7 +191,7 @@ "lunr": "2.3.9", "multer": "2.0.2", "ngx-toastr": "19.1.0", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "oidc-client-ts": "2.4.1", "open": "11.0.0",