diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index edbc9d98cc9..224020991d1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,27 +9,3 @@ ## 📸 Screenshots - -## ⏰ Reminders before review - -- Contributor guidelines followed -- All formatters and local linters executed and passed -- Written new unit and / or integration tests where applicable -- Protected functional changes with optionality (feature flags) -- Used internationalization (i18n) for all UI strings -- CI builds passed -- Communicated to DevOps any deployment requirements -- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team - -## 🦮 Reviewer guidelines - - - -- 👍 (`:+1:`) or similar for great changes -- 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info -- ❓ (`:question:`) for questions -- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion -- 🎨 (`:art:`) for suggestions / improvements -- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention -- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt -- ⛏ (`:pick:`) for minor or nitpick changes diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 6b652149d8d..701e6208b60 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -57,7 +57,7 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Verify @@ -90,7 +90,7 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: true - name: Get Package Version @@ -176,7 +176,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Free disk space @@ -335,7 +335,7 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Set up Node @@ -483,7 +483,7 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Set up Node @@ -996,7 +996,7 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Set up Node @@ -1236,7 +1236,7 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Set up Node @@ -1511,7 +1511,7 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Set up Node @@ -1852,7 +1852,7 @@ jobs: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Log in to Azure @@ -1894,15 +1894,16 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download deb artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/linux/deb artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb @@ -1937,15 +1938,16 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download appimage artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/linux/appimage artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage @@ -1978,15 +1980,16 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download appimage artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/linux/appimage artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage @@ -2033,15 +2036,16 @@ jobs: - linux-arm64 steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download flatpak artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/linux/flatpak/ artifacts: com.bitwarden.${{ matrix.os == 'ubuntu-22.04' && 'desktop' || 'desktop-arm64' }}.flatpak @@ -2086,15 +2090,16 @@ jobs: _CPU_ARCH: ${{ matrix.os == 'ubuntu-22.04' && 'amd64' || 'arm64' }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download snap artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/linux/snap artifacts: bitwarden_${{ env._PACKAGE_VERSION }}_${{ env._CPU_ARCH }}.snap @@ -2130,15 +2135,16 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download dmg artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/macos/dmg artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg @@ -2174,15 +2180,16 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false - name: Download portable artifact uses: bitwarden/gh-actions/download-artifacts@main with: + run_id: ${{ github.run_id }} path: apps/desktop/artifacts/windows/portable artifacts: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ea6894dab8e..83c931b4fe0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -115,7 +115,7 @@ jobs: run: rustup --version - name: Cache cargo registry - uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - name: Run cargo fmt working-directory: ./apps/desktop/desktop_native @@ -128,7 +128,7 @@ jobs: RUSTFLAGS: "-D warnings" - name: Install cargo-sort - run: cargo install cargo-sort --locked --git https://github.com/DevinR528/cargo-sort.git --rev f5047967021cbb1f822faddc355b3b07674305a1 + run: cargo install cargo-sort --locked --git https://github.com/DevinR528/cargo-sort.git --rev ac6e328faf467a39e38ab48dc60dcf4f6a46d7a5 # v2.0.2 - name: Cargo sort working-directory: ./apps/desktop/desktop_native diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index bfea785fc63..3b9ca37f6b4 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "خيارات تسجيل الدخول بخطوتين المملوكة لجهات اخرى مثل YubiKey و Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "نظافة كلمة المرور، صحة الحساب، وتقارير تسريبات البيانات للحفاظ على سلامة خزانتك." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index f94b0bfc501..5eeedc430b1 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Element arxivə göndərildi" }, + "itemWasUnarchived": { + "message": "Element arxivdən çıxarıldı" + }, "itemUnarchived": { "message": "Element arxivdən çıxarıldı" }, @@ -582,11 +585,17 @@ "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?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "Arxivi istifadə etmək üçün premium üzvlük tələb olunur." }, "itemRestored": { - "message": "Item has been restored" + "message": "Element bərpa edildi" }, "edit": { "message": "Düzəliş et" @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "YubiKey və Duo kimi mülkiyyətçi iki addımlı giriş seçimləri." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Seyfinizi güvəndə saxlamaq üçün parol gigiyenası, hesab sağlamlığı və veri pozuntusu hesabatları." }, @@ -4815,19 +4833,34 @@ "message": "Admin" }, "automaticUserConfirmation": { - "message": "Automatic user confirmation" + "message": "Avtomatik istifadəçi təsdiqi" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "message": "Bu cihazın kilidi açıq olduqda gözləyən istifadəçiləri avtomatik təsdiqlə" }, "autoConfirmOnboardingCallout": { - "message": "Save time with automatic user confirmation" + "message": "Avtomatik istifadəçi təsdiqi ilə vaxta qənaət edin" }, "autoConfirmWarning": { - "message": "This could impact your organization’s data security. " + "message": "Bu, təşkilatınızın veri təhlükəsizliyinə təsir edə bilər. " }, "autoConfirmWarningLink": { - "message": "Learn about the risks" + "message": "Risklər barədə öyrən" + }, + "autoConfirmSetup": { + "message": "Yeni istifadəçiləri avtomatik təsdiqlə" + }, + "autoConfirmSetupDesc": { + "message": "Bu cihazın kilidi açıq olduqda yeni istifadəçilər avtomatik təsdiqlənəcək." + }, + "autoConfirmSetupHint": { + "message": "Potensial təhlükəsizlik riskləri nələrdir?" + }, + "autoConfirmEnabled": { + "message": "Avtomatik təsdiq işə salındı" + }, + "availableNow": { + "message": "İndi mmövcuddur" }, "accountSecurity": { "message": "Hesab güvənliyi" @@ -5686,10 +5719,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" diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 81f7fefdd9f..96042f12e19 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Прапрыетарныя варыянты двухэтапнага ўваходу, такія як YubiKey і Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Гігіена пароляў, здароўе ўліковага запісу і справаздачы аб уцечках даных для забеспячэння бяспекі вашага сховішча." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Бяспеке акаўнта" }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 4fcca092639..9a420b1d177 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Елементът беше преместен в архива" }, + "itemWasUnarchived": { + "message": "Елементът беше изваден от архива" + }, "itemUnarchived": { "message": "Елементът беше изваден от архива" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" }, + "archived": { + "message": "Архивирано" + }, + "unarchiveAndSave": { + "message": "Разархивиране и запазване" + }, "upgradeToUseArchive": { "message": "За да се възползвате от архивирането, трябва да ползвате платен абонамент." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Частно двустепенно удостоверяване чрез YubiKey и Duo." }, + "premiumSubscriptionEnded": { + "message": "Вашият абонамент за платения план е приключил" + }, + "archivePremiumRestart": { + "message": "Ако искате отново да получите достъп до архива си, трябва да подновите платения си абонамент. Ако редактирате данните за архивиран елемент преди подновяването, той ще бъде върнат в трезора." + }, + "restartPremium": { + "message": "Подновяване на платения абонамент" + }, "ppremiumSignUpReports": { "message": "Проверки в списъците с публикувани пароли, проверка на регистрациите и доклади за пробивите в сигурността, което спомага трезорът ви да е допълнително защитен." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Научете повече за рисковете" }, + "autoConfirmSetup": { + "message": "Автоматично потвърждаване на новите потребители" + }, + "autoConfirmSetupDesc": { + "message": "Новите потребители ще бъдат потвърждавани автоматично, докато това устройство е отключено." + }, + "autoConfirmSetupHint": { + "message": "Какви са възможните рискове за сигурността?" + }, + "autoConfirmEnabled": { + "message": "Автоматичното потвърждаване е включено" + }, + "availableNow": { + "message": "Налично сега" + }, "accountSecurity": { "message": "Защита на регистрацията" }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 753418ab603..78b55611b50 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "আপনার ভল্টটি সুরক্ষিত রাখতে পাসওয়ার্ড স্বাস্থ্যকরন, অ্যাকাউন্ট স্বাস্থ্য এবং ডেটা লঙ্ঘনের প্রতিবেদন।" }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index a9a6c77e7ae..3bd578aa2a3 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index c195fd8c0b2..97b9911536a 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opcions propietàries de doble factor com ara YubiKey i Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Requisits d'higiene de la contrasenya, salut del compte i informe d'infraccions de dades per mantenir la seguretat de la vostra caixa forta." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Seguretat del compte" }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 1087cf67c7c..4c88374ed3e 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Položka byla přesunuta do archivu" }, + "itemWasUnarchived": { + "message": "Položka byla odebrána z archivu" + }, "itemUnarchived": { "message": "Položka byla odebrána z archivu" }, @@ -582,6 +585,12 @@ "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?" }, + "archived": { + "message": "Archivováno" + }, + "unarchiveAndSave": { + "message": "Odebrat z archivu a uložit" + }, "upgradeToUseArchive": { "message": "Pro použití funkce Archiv je potřebné prémiové členství." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Volby proprietálních dvoufázových přihlášení jako je YubiKey a Duo." }, + "premiumSubscriptionEnded": { + "message": "Vaše předplatné Premium skončilo" + }, + "archivePremiumRestart": { + "message": "Chcete-li získat přístup k Vašemu archivu, restartujte předplatné Premium. Pokud upravíte detaily archivované položky před restartováním, bude přesunuta zpět do Vašeho trezoru." + }, + "restartPremium": { + "message": "Restartovat Premium" + }, "ppremiumSignUpReports": { "message": "Reporty o hygieně Vašich hesel, zdraví účtu a narušeních bezpečnosti." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Více o rizicích" }, + "autoConfirmSetup": { + "message": "Automaticky potvrdit nové uživatele" + }, + "autoConfirmSetupDesc": { + "message": "Noví uživatelé budou automaticky potvrzeni, když bude toto zařízení odemčeno." + }, + "autoConfirmSetupHint": { + "message": "Jaká jsou možná bezpečnostní rizika?" + }, + "autoConfirmEnabled": { + "message": "Zapnuto automatické potvrzení" + }, + "availableNow": { + "message": "Nyní k dispozici" + }, "accountSecurity": { "message": "Zabezpečení účtu" }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 0b8d45e4042..64a240add67 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -344,16 +344,16 @@ "message": "Bitwarden for Business" }, "bitwardenAuthenticator": { - "message": "Dilyswr Bitwarden" + "message": "Dilysydd Bitwarden" }, "continueToAuthenticatorPageDesc": { "message": "Bitwarden Authenticator allows you to store authenticator keys and generate TOTP codes for 2-step verification flows. Learn more on the bitwarden.com website" }, "bitwardenSecretsManager": { - "message": "Bitwarden Secrets Manager" + "message": "Rheolydd Cyfrinachau Bitwarden" }, "continueToSecretsManagerPageDesc": { - "message": "Securely store, manage, and share developer secrets with Bitwarden Secrets Manager. Learn more on the bitwarden.com website." + "message": "Gallwch storio, rheoli a rhannu cyfrinachau datblygwyr yn ddiogel gyda Rheolydd Cyfrinachau Bitwarden. Dysgwch fwy ar wefan bitwarden.com." }, "passwordlessDotDev": { "message": "Passwordless.dev" @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Dewisiadau mewngofnodi dau gam perchenogol megis YubiKey a Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4155,7 +4173,7 @@ "description": "Text to display in overlay when the account is locked." }, "unlockYourAccountToViewAutofillSuggestions": { - "message": "Unlock your account to view autofill suggestions", + "message": "Datglowch eich cyfrif i weld argymhellion llenwi awtomatig", "description": "Text to display in overlay when the account is locked." }, "unlockAccount": { @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Diogelwch eich cyfrif" }, @@ -4957,7 +4990,7 @@ "message": "Download Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Download Bitwarden on all devices" + "message": "Lawrlwytho Bitwarden ar bob dyfais" }, "getTheMobileApp": { "message": "Get the mobile app" @@ -4987,7 +5020,7 @@ "message": "Premium" }, "unlockFeaturesWithPremium": { - "message": "Unlock reporting, emergency access, and more security features with Premium." + "message": "Datglowch nodweddion diogelwch megis adroddiadau, mynediad mewn argyfwng, a mwy drwy gyfrif Premium." }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" @@ -4996,7 +5029,7 @@ "message": "Filters" }, "filterVault": { - "message": "Filter vault" + "message": "Hidlo'r gell" }, "filterApplied": { "message": "One filter applied" @@ -5882,7 +5915,7 @@ "message": "Great job securing your at-risk logins!" }, "upgradeNow": { - "message": "Upgrade now" + "message": "Uwchraddio nawr" }, "builtInAuthenticator": { "message": "Built-in authenticator" diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 8b35b2049ca..640dbaf89b7 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietære totrins-login muligheder, såsom YubiKey og Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Adgangskodehygiejne, kontosundhed og rapporter om datalæk til at holde din boks sikker." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Kontosikkerhed" }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 8363d97c5e2..ce72944943c 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Eintrag wurde archiviert" }, + "itemWasUnarchived": { + "message": "Eintrag wird nicht mehr archiviert" + }, "itemUnarchived": { "message": "Eintrag wird nicht mehr archiviert" }, @@ -582,6 +585,12 @@ "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?" }, + "archived": { + "message": "Archiviert" + }, + "unarchiveAndSave": { + "message": "Nicht mehr archivieren und speichern" + }, "upgradeToUseArchive": { "message": "Für die Nutzung des Archivs ist eine Premium-Mitgliedschaft erforderlich." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietäre Optionen für die Zwei-Faktor Authentifizierung wie YubiKey und Duo." }, + "premiumSubscriptionEnded": { + "message": "Dein Premium-Abonnement ist abgelaufen" + }, + "archivePremiumRestart": { + "message": "Starte dein Premium-Abonnement neu, um den Zugriff auf dein Archiv wiederherzustellen. Wenn du die Details für einen archivierten Eintrag vor dem Neustart bearbeitest, wird er wieder zurück in deinen Tresor verschoben." + }, + "restartPremium": { + "message": "Premium neu starten" + }, "ppremiumSignUpReports": { "message": "Berichte über Kennworthygiene, Kontostatus und Datenschutzverletzungen, um deinen Tresor sicher zu halten." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Erfahre mehr über die Risiken" }, + "autoConfirmSetup": { + "message": "Neue Benutzer automatisch bestätigen" + }, + "autoConfirmSetupDesc": { + "message": "Neue Benutzer werden automatisch bestätigt, während dieses Gerät entsperrt ist." + }, + "autoConfirmSetupHint": { + "message": "Was sind die möglichen Sicherheitsrisiken?" + }, + "autoConfirmEnabled": { + "message": "Automatische Bestätigung aktiviert" + }, + "availableNow": { + "message": "Jetzt verfügbar" + }, "accountSecurity": { "message": "Kontosicherheit" }, diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index ea86a8137a8..f20edf63793 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Πρόσθετες επιλογές σύνδεσης δύο βημάτων, όπως το YubiKey και το Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Ασφάλεια κωδικών, υγεία λογαριασμού και αναφορές παραβίασης δεδομένων για να διατηρήσετε ασφαλές το vault σας." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Ασφάλεια λογαριασμού" }, diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 1613373bd62..90cc4a5c338 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -573,14 +573,23 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, "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" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4721,6 +4739,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.", @@ -5107,14 +5128,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?" diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 4b773754d24..4fd70672f4a 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index d841974a386..a92aac915c1 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index fab3e5f5bd9..f72635696a8 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opciones de inicio de sesión con autenticación de dos pasos propietarios como YubiKey y Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Higiene de contraseña, salud de la cuenta e informes de violaciones de datos para mantener su caja fuerte segura." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Seguridad de la cuenta" }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 674fc7f85e9..a7730b536f7 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Parooli hügieen, konto seisukord ja andmelekete raportid aitavad hoidlat turvalisena hoida." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index 175dff36dce..7a6ca24b3da 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Pasahitzaren higienea, kontuaren egoera eta datu-bortxaketen txostenak, kutxa gotorra seguru mantentzeko." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 770c83f1d0e..c5af707763f 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "گزینه‌های ورود اضافی دو مرحله‌ای مانند YubiKey و Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "گزارش‌های بهداشت کلمه عبور، سلامت حساب کاربری و نقض داده‌ها برای ایمن نگهداشتن گاوصندوق شما." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "امنیت حساب کاربری" }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 3ad41bc0b78..cfab00e0849 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Kaksivaiheisen kirjautumisen erikoisvaihtoehdot, kuten YubiKey ja Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Salasanahygienian, tilin terveyden ja tietovuotojen raportointitoiminnot pitävät holvisi turvassa." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Tilin suojaus" }, diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index d6d34948cf4..91fe7f70ced 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Pagmamay-ari na dalawang hakbang na opsyon sa pag-log in gaya ng YubiKey at Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Pasahod higiyena, kalusugan ng account, at mga ulat sa data breach upang panatilihing ligtas ang iyong vault." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 56604fa3a10..e884c3cd141 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "L'élément a été envoyé à l'archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "L'élément a été désarchivé" }, @@ -582,6 +585,12 @@ "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 ?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "Une adhésion premium est requise pour utiliser Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Options de connexion propriétaires à deux facteurs telles que YubiKey et Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Hygiène du mot de passe, santé du compte et rapports sur les brèches de données pour assurer la sécurité de votre coffre." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Sécurité du compte" }, diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index efd1396eac9..27c66bb1ce6 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opcións de verificación en 2 pasos privadas tales coma YubiKey ou Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Limpeza de contrasinais, saúde de contas e informes de filtración de datos para manter a túa caixa forte segura." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Seguridade da conta" }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 2b466187b70..8c09d562f60 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "הפריט נשלח לארכיון" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "הפריט הוסר מהארכיון" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "פריטים בארכיון מוחרגים מתוצאות חיפוש כללי והצעות למילוי אוטומטי. האם אתה בטוח שברצונך להעביר פריט זה לארכיון?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "אפשרויות כניסה דו־שלבית קנייניות כגון YubiKey ו־Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "היגיינת סיסמאות, מצב בריאות החשבון, ודיווחים מעודכנים על פרצות חדשות בכדי לשמור על הכספת שלך בטוחה." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "אבטחת החשבון" }, diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 5458ff6f671..50fc056527a 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "अपनी वॉल्ट को सुरक्षित रखने के लिए पासवर्ड स्वच्छता, खाता स्वास्थ्य और डेटा उल्लंघन रिपोर्ट।" }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index d0a46669fd9..3e94ef40a30 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Stavka poslana u arhivu" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Stavka vraćena iz arhive" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Arhivirane stavke biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune. Sigurno želiš arhivirati?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Mogućnosti za prijavu u dva koraka kao što su YubiKey i Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Higijenu lozinki, zdravlje računa i izvještaje o krađi podatak radi zaštite svojeg trezora." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Sigurnost računa" }, diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 38cf27cfdc2..3977f7a4fb5 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Az elem az archivumba került." }, + "itemWasUnarchived": { + "message": "Az elem visszavételre került az archivumból." + }, "itemUnarchived": { "message": "Az elemek visszavéelre kerültek az archivumból." }, @@ -582,6 +585,12 @@ "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?" }, + "archived": { + "message": "Archiválva" + }, + "unarchiveAndSave": { + "message": "Archiválás visszavonása és mentés" + }, "upgradeToUseArchive": { "message": "Az Archívum használatához prémium tagság szükséges." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Saját kétlépcsős bejelentkezési lehetőségek mint a YubiKey és a Duo." }, + "premiumSubscriptionEnded": { + "message": "A Prémium előfizetés véget ért." + }, + "archivePremiumRestart": { + "message": "Az archívumhoz hozzáférés visszaszerzéséhez indítsuk újra a Prémium előfizetést. Ha az újraindítás előtt szerkesztjük egy archivált elem adatait, akkor az visszakerül a széfbe." + }, + "restartPremium": { + "message": "Prémium előfizetés újraindítása" + }, "ppremiumSignUpReports": { "message": "Jelszó higiénia, fiók biztonság és adatszivárgási jelentések a széf biztonsága érdekében." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "További információ a kockázatokról" }, + "autoConfirmSetup": { + "message": "Új felhasználók automatikus megerősítése" + }, + "autoConfirmSetupDesc": { + "message": "Az új felhasználók automatikusan megerősítésre kerülnek, amíg ez az eszköz fel van oldva." + }, + "autoConfirmSetupHint": { + "message": "Melyek a lehetséges biztonsági kockázatok?" + }, + "autoConfirmEnabled": { + "message": "Az automatikus megerősítés bekapcsolásra került." + }, + "availableNow": { + "message": "Elérhető most" + }, "accountSecurity": { "message": "Fiókbiztonság" }, diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 9d50cc7f048..08d346d57d8 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Pilihan masuk dua-langkah yang dipatenkan seperti YubiKey dan Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Kebersihan kata sandi, kesehatan akun, dan laporan kebocoran data untuk tetap menjaga keamanan brankas Anda." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Keamanan akun" }, diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 2fd85c2096e..32fe8e9ce54 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Elemento archiviato" }, + "itemWasUnarchived": { + "message": "Elemento rimosso dall'archivio" + }, "itemUnarchived": { "message": "Elemento rimosso dall'archivio" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Gli elementi archiviati sono esclusi dai risultati di ricerca e suggerimenti di autoriempimento. Vuoi davvero archiviare questo elemento?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "Per utilizzare Archivio è necessario un abbonamento premium." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opzioni di verifica in due passaggi proprietarie come YubiKey e Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Sicurezza delle password, integrità dell'account, e rapporti su violazioni di dati per mantenere sicura la tua cassaforte." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Scopri quali sono i rischi" }, + "autoConfirmSetup": { + "message": "Conferma automaticamente i nuovi utenti" + }, + "autoConfirmSetupDesc": { + "message": "I nuovi utenti saranno automaticamente confermati mentre questo dispositivo è sbloccato." + }, + "autoConfirmSetupHint": { + "message": "Quali sono i rischi potenziali per la sicurezza?" + }, + "autoConfirmEnabled": { + "message": "Conferma automatica attivata" + }, + "availableNow": { + "message": "Disponibile ora" + }, "accountSecurity": { "message": "Sicurezza dell'account" }, diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index ed377335590..0abea0e0236 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "アイテムはアーカイブに送信されました" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "アイテムはアーカイブから解除されました" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "アーカイブされたアイテムはここに表示され、通常の検索結果および自動入力の候補から除外されます。このアイテムをアーカイブしますか?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "アーカイブを使用するにはプレミアムメンバーシップが必要です。" }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "YubiKey、Duo などのプロプライエタリな2段階認証オプション。" }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "保管庫を安全に保つための、パスワードやアカウントの健全性、データ侵害に関するレポート" }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "アカウントのセキュリティ" }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index b54ca90d920..dc104c4a7f3 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "ანგარიშის უსაფრთხოება" }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 26184ebf9c1..db750969f43 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index ec287d84d9d..7e0f427ea69 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "ನಿಮ್ಮ ವಾಲ್ಟ್ ಅನ್ನು ಸುರಕ್ಷಿತವಾಗಿರಿಸಲು ಪಾಸ್ವರ್ಡ್ ನೈರ್ಮಲ್ಯ, ಖಾತೆ ಆರೋಗ್ಯ ಮತ್ತು ಡೇಟಾ ಉಲ್ಲಂಘನೆ ವರದಿಗಳು." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 14c97b086fe..602c54c5f64 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "항목이 보관함으로 이동되었습니다" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "항목 보관 해제됨" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "보관된 항목은 일반 검색 결과와 자동 완성 제안에서 제외됩니다. 이 항목을 보관하시겠습니까?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "YubiKey나 Duo와 같은 독점적인 2단계 로그인 옵션" }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "보관함을 안전하게 유지하기 위한 암호 위생, 계정 상태, 데이터 유출 보고서" }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "계정 보안" }, diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 44574d5bae8..4ba52318c0b 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Patentuotos dviejų žingsnių prisijungimo parinktys, tokios kaip YubiKey ir Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Slaptažodžio higiena, prieigos sveikata ir duomenų nutekinimo ataskaitos, kad tavo saugyklas būtų saugus." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Paskyros saugumas" }, diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index eca6a7f92af..e75255ab829 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Vienums tika ievietots arhīvā" }, + "itemWasUnarchived": { + "message": "Vienums tika izņemts no arhīva" + }, "itemUnarchived": { "message": "Vienums tika izņemts no arhīva" }, @@ -582,6 +585,12 @@ "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?" }, + "archived": { + "message": "Arhivēts" + }, + "unarchiveAndSave": { + "message": "Atcelt arhivēšanu un saglabāt" + }, "upgradeToUseArchive": { "message": "Ir nepieciešama Premium dalība, lai izmantotu arhīvu." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Tādas slēgtā pirmavota divpakāpju pieteikšanās iespējas kā YubiKey un Duo." }, + "premiumSubscriptionEnded": { + "message": "Tavs Premium abonements beidzās" + }, + "archivePremiumRestart": { + "message": "Lai atgūtu piekļuvi savam arhīvam, jāatsāk Premium abonements. Ja labosi arhivēta vienuma informāciju pirms atsākšanas, tas tiks pārvietots atpakaļ Tavā glabātavā." + }, + "restartPremium": { + "message": "Atsākt Premium" + }, "ppremiumSignUpReports": { "message": "Paroļu higiēnas, konta veselības un datu noplūžu pārskati, lai uzturētu glabātavu drošu." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Uzzināt par riskiem" }, + "autoConfirmSetup": { + "message": "Automātiski apstiprināt jaunus lietotājus" + }, + "autoConfirmSetupDesc": { + "message": "Jauni lietotāji tiks automātiski apstiprināti, kamēr šī ierīce ir atslēgta." + }, + "autoConfirmSetupHint": { + "message": "Kādi ir iespējamie drošības riski?" + }, + "autoConfirmEnabled": { + "message": "Automātiska apstiprināšana ieslēgta" + }, + "availableNow": { + "message": "Pieejams tagad" + }, "accountSecurity": { "message": "Konta drošība" }, diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 49afe2b7db5..7afe2b75287 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "നിങ്ങളുടെ വാൾട് സൂക്ഷിക്കുന്നതിന്. പാസ്‌വേഡ് ശുചിത്വം, അക്കൗണ്ട് ആരോഗ്യം, ഡാറ്റ ലംഘന റിപ്പോർട്ടുകൾ." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index db264f4d571..4d355428078 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 26184ebf9c1..db750969f43 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index c588aa0ef39..47f9ef4f7a9 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Passordhygiene, kontohelse, og databruddsrapporter som holder hvelvet ditt trygt." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Kontosikkerhet" }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 26184ebf9c1..db750969f43 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index d4bb4a8f6d9..0157fa800f9 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item naar archief verzonden" }, + "itemWasUnarchived": { + "message": "Item uit het archief gehaald" + }, "itemUnarchived": { "message": "Item uit het archief gehaald" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Gearchiveerde items worden uitgesloten van algemene zoekresultaten en automatische invulsuggesties. Weet je zeker dat je dit item wilt archiveren?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "Je hebt een Premium-abonnement nodig om te kunnen archiveren." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Eigen opties voor tweestapsaanmelding zoals YubiKey en Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Wachtwoordhygiëne, gezondheid van je account en datalekken om je kluis veilig te houden." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Meer informatie over de risico's" }, + "autoConfirmSetup": { + "message": "Automatisch nieuwe gebruikers bevestigen" + }, + "autoConfirmSetupDesc": { + "message": "Nieuwe gebruikers worden automatisch bevestigd wanneer dit apparaat is ontgrendeld." + }, + "autoConfirmSetupHint": { + "message": "Wat zijn de mogelijke veiligheidsrisico's?" + }, + "autoConfirmEnabled": { + "message": "Automatische bevestigen ingeschakeld" + }, + "availableNow": { + "message": "Nu beschikbaar" + }, "accountSecurity": { "message": "Accountbeveiliging" }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 26184ebf9c1..db750969f43 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 26184ebf9c1..db750969f43 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 4392b744e97..705fa9565f1 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Element został przeniesiony do archiwum" }, + "itemWasUnarchived": { + "message": "Element został usunięty z archiwum" + }, "itemUnarchived": { "message": "Element został usunięty z archiwum" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Zarchiwizowane elementy są wykluczone z wyników wyszukiwania i sugestii autouzupełniania. Czy na pewno chcesz archiwizować element?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Specjalne opcje logowania dwustopniowego, takie jak YubiKey i Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Raporty bezpieczeństwa haseł, konta i wycieków danych, aby Twoje dane były bezpieczne." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Bezpieczeństwo konta" }, diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index a21fefd54a5..5027fb35af2 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "O item foi enviado para o arquivo" }, + "itemWasUnarchived": { + "message": "O item foi desarquivado" + }, "itemUnarchived": { "message": "O item foi desarquivado" }, @@ -582,6 +585,12 @@ "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?" }, + "archived": { + "message": "Arquivados" + }, + "unarchiveAndSave": { + "message": "Desarquivar e salvar" + }, "upgradeToUseArchive": { "message": "Um plano Premium é necessário para usar o arquivamento." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opções de autenticação em duas etapas proprietárias como YubiKey e Duo." }, + "premiumSubscriptionEnded": { + "message": "Sua assinatura Premium terminou" + }, + "archivePremiumRestart": { + "message": "Para recuperar o seu acesso ao seu arquivo, retoma sua assinatura Premium. Se editar detalhes de um item arquivado antes de retomar, ele será movido de volta para o seu cofre." + }, + "restartPremium": { + "message": "Retomar Premium" + }, "ppremiumSignUpReports": { "message": "Relatórios de higiene de senha, saúde da conta, e vazamentos de dados para manter o seu cofre seguro." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Saiba mais sobre os riscos" }, + "autoConfirmSetup": { + "message": "Confirmar automaticamente usuários novos" + }, + "autoConfirmSetupDesc": { + "message": "Usuários novos serão confirmados automaticamente quando este dispositivo for desbloqueado." + }, + "autoConfirmSetupHint": { + "message": "Quais são os possíveis problemas de segurança?" + }, + "autoConfirmEnabled": { + "message": "Ativou a confirmação automática" + }, + "availableNow": { + "message": "Disponível agora" + }, "accountSecurity": { "message": "Segurança da conta" }, diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 69c97e5ad78..7f97ab08623 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "O item foi movido para o arquivo" }, + "itemWasUnarchived": { + "message": "O item foi desarquivado" + }, "itemUnarchived": { "message": "O item foi desarquivado" }, @@ -582,6 +585,12 @@ "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?" }, + "archived": { + "message": "Arquivado" + }, + "unarchiveAndSave": { + "message": "Desarquivar e guardar" + }, "upgradeToUseArchive": { "message": "É necessária uma subscrição Premium para utilizar o Arquivo." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opções proprietárias de verificação de dois passos, como YubiKey e Duo." }, + "premiumSubscriptionEnded": { + "message": "A sua subscrição Premium terminou" + }, + "archivePremiumRestart": { + "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" + }, "ppremiumSignUpReports": { "message": "Higiene de palavras-passe, saúde da conta e relatórios de violação de dados para manter o seu cofre seguro." }, @@ -4818,16 +4836,31 @@ "message": "Confirmação automática do utilizador" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "message": "Confirmar automaticamente os utilizadores pendentes enquanto este dispositivo estiver desbloqueado" }, "autoConfirmOnboardingCallout": { - "message": "Save time with automatic user confirmation" + "message": "Poupe tempo com a confirmação automática de utilizadores" }, "autoConfirmWarning": { - "message": "This could impact your organization’s data security. " + "message": "Isto pode afetar a segurança dos dados da sua organização. " }, "autoConfirmWarningLink": { - "message": "Learn about the risks" + "message": "Saiba mais sobre os riscos" + }, + "autoConfirmSetup": { + "message": "Confirmar automaticamente novos utilizadores" + }, + "autoConfirmSetupDesc": { + "message": "Os novos utilizadores serão automaticamente confirmados enquanto este dispositivo estiver desbloqueado." + }, + "autoConfirmSetupHint": { + "message": "Quais são os riscos potenciais de segurança?" + }, + "autoConfirmEnabled": { + "message": "Confirmação automática ativada" + }, + "availableNow": { + "message": "Já disponível" }, "accountSecurity": { "message": "Segurança da conta" diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 40faec2782a..139b94d1a55 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Opțiuni brevetate de conectare cu doi factori, cum ar fi YubiKey și Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Rapoarte privind igiena parolelor, sănătatea contului și breșele de date pentru a vă păstra seiful în siguranță." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 64e9c877c48..0ab286c819d 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Элемент был отправлен в архив" }, + "itemWasUnarchived": { + "message": "Элемент был разархивирован" + }, "itemUnarchived": { "message": "Элемент был разархивирован" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "Для использования архива требуется премиум-статус." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Проприетарные варианты двухэтапной аутентификации, такие как YubiKey или Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Гигиена паролей, здоровье аккаунта и отчеты об утечках данных для обеспечения безопасности вашего хранилища." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Узнайте о рисках" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Безопасность аккаунта" }, diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 79ff6de2618..82fed7f5fd4 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "ඔබගේ සුරක්ෂිතාගාරය ආරක්ෂිතව තබා ගැනීම සඳහා මුරපදය සනීපාරක්ෂාව, ගිණුම් සෞඛ්යය සහ දත්ත උල්ලං ach නය වාර්තා කරයි." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 6b8d752a7fc..f5619ef017d 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Položka bola archivovaná" }, + "itemWasUnarchived": { + "message": "Položka bola odobraná z archívu" + }, "itemUnarchived": { "message": "Položka bola odobraná z archívu" }, @@ -582,6 +585,12 @@ "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?" }, + "archived": { + "message": "Archivované" + }, + "unarchiveAndSave": { + "message": "Zrušiť archiváciu a uložiť" + }, "upgradeToUseArchive": { "message": "Na použitie archívu je potrebné prémiové členstvo." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietárne možnosti dvojstupňového prihlásenia ako napríklad YubiKey a Duo." }, + "premiumSubscriptionEnded": { + "message": "Vaše predplatné Prémium skončilo" + }, + "archivePremiumRestart": { + "message": "Ak chcete obnoviť prístup k svojmu archívu, reštartujte predplatné Prémium. Ak pred reštartom upravíte podrobnosti archivovanej položky, bude presunutá späť do trezoru." + }, + "restartPremium": { + "message": "Reštartovať Prémium" + }, "ppremiumSignUpReports": { "message": "Správy o sile hesla, zabezpečení účtov a únikoch dát ktoré vám pomôžu udržať vaše kontá v bezpečí." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Dozvedieť sa viac o rizikách" }, + "autoConfirmSetup": { + "message": "Automaticky potvrdzovať nových používateľov" + }, + "autoConfirmSetupDesc": { + "message": "Noví používatelia budú automaticky potvrdení, keď je toto zariadenie odomknuté." + }, + "autoConfirmSetupHint": { + "message": "Aké sú potenciálne bezpečnostné riziká?" + }, + "autoConfirmEnabled": { + "message": "Zapnuté automatické potvrdzovanie" + }, + "availableNow": { + "message": "Teraz dostupné" + }, "accountSecurity": { "message": "Zabezpečenie účtu" }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 3f82082adf2..a0ccbdeadbc 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Higiena gesel, zdravje računa in poročila o kraji podatkov, ki vam pomagajo ohraniti varnost vašega trezorja." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 59e1ce9335e..f5e85127639 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Ставка је послата у архиву" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Ставка враћена из архиве" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Архивиране ставке су искључене из општих резултата претраге и предлога за ауто попуњавање. Јесте ли сигурни да желите да архивирате ову ставку?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "Премијум чланство је неопходно за употребу Архиве." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Приоритарне опције пријаве у два корака као што су YubiKey и Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Извештаји о хигијени лозинки, здравственом стању налога и кршењу података да бисте заштитили сеф." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Безбедност налога" }, diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 81a7fda2e39..2aef3f0d6d8 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Objektet skickades till arkivet" }, + "itemWasUnarchived": { + "message": "Objektet har avarkiverats" + }, "itemUnarchived": { "message": "Objektet har avarkiverats" }, @@ -582,6 +585,12 @@ "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?" }, + "archived": { + "message": "Arkiverade" + }, + "unarchiveAndSave": { + "message": "Avarkivera och spara" + }, "upgradeToUseArchive": { "message": "Ett premium-medlemskap krävs för att använda Arkiv." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Premium-alternativ för tvåstegsverifiering, såsom YubiKey och Duo." }, + "premiumSubscriptionEnded": { + "message": "Ditt Premium-abonnemang avslutades" + }, + "archivePremiumRestart": { + "message": "För att återfå åtkomst till ditt arkiv, starta om Premium-abonnemanget. Om du redigerar detaljer för ett arkiverat objekt innan du startar om kommer det att flyttas tillbaka till ditt valv." + }, + "restartPremium": { + "message": "Starta om Premium" + }, "ppremiumSignUpReports": { "message": "Lösenordshygien, kontohälsa och dataintrångsrapporter för att hålla ditt valv säkert." }, @@ -4812,22 +4830,37 @@ "message": "Adminkonsol" }, "admin": { - "message": "Admin" + "message": "Administratör" }, "automaticUserConfirmation": { - "message": "Automatic user confirmation" + "message": "Automatisk bekräftelse av användare" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "message": "Bekräfta automatiskt väntande användare medan enheten är olåst" }, "autoConfirmOnboardingCallout": { - "message": "Save time with automatic user confirmation" + "message": "Spara tid med automatisk användarbekräftelse" }, "autoConfirmWarning": { - "message": "This could impact your organization’s data security. " + "message": "Detta kan påverka din organisations datasäkerhet. " }, "autoConfirmWarningLink": { - "message": "Learn about the risks" + "message": "Läs mer om riskerna" + }, + "autoConfirmSetup": { + "message": "Bekräfta nya användare automatiskt" + }, + "autoConfirmSetupDesc": { + "message": "Nya användare kommer automatiskt att bekräftas när denna enhet är upplåst." + }, + "autoConfirmSetupHint": { + "message": "Vilka är de potentiella säkerhetsriskerna?" + }, + "autoConfirmEnabled": { + "message": "Aktiverade automatisk bekräftelse" + }, + "availableNow": { + "message": "Tillgänglig nu" }, "accountSecurity": { "message": "Kontosäkerhet" diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index db9e2f519af..c329028526a 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "ஆவணம் காப்பகத்திற்கு அனுப்பப்பட்டது" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "காப்பகம் மீட்டெடுக்கப்பட்டது" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "காப்பகப்படுத்தப்பட்ட உருப்படிகள் பொதுவான தேடல் முடிவுகள் மற்றும் தானியங்குநிரப்பு பரிந்துரைகளிலிருந்து விலக்கப்பட்டுள்ளன. இந்த உருப்படியை காப்பகப்படுத்த விரும்புகிறீர்களா?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "காப்பகத்தைப் பயன்படுத்த பிரீமியம் உறுப்பினர் தேவை." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "YubiKey மற்றும் Duo போன்ற பிரத்யேக டூ-ஸ்டெப் உள்நுழைவு விருப்பங்கள்." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "உங்கள் வால்ட்டைப் பாதுகாப்பாக வைத்திருக்க கடவுச்சொல் சுகாதாரம், கணக்கின் ஆரோக்கியம் மற்றும் டேட்டா மீறல் அறிக்கைகள்." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "கணக்கு பாதுகாப்பு" }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 26184ebf9c1..db750969f43 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Proprietary two-step login options such as YubiKey and Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 53a2c56ae94..49ed1cebd89 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "ย้ายรายการไปที่จัดเก็บถาวรแล้ว" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "เลิกจัดเก็บถาวรรายการแล้ว" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "รายการที่จัดเก็บถาวรจะไม่ถูกรวมในผลการค้นหาทั่วไปและคำแนะนำการป้อนอัตโนมัติ ยืนยันที่จะจัดเก็บรายการนี้ถาวรหรือไม่" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "ต้องเป็นสมาชิกพรีเมียมจึงจะใช้งานฟีเจอร์จัดเก็บถาวรได้" }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "ตัวเลือกการเข้าสู่ระบบ 2 ขั้นตอนแบบพิเศษ เช่น YubiKey และ Duo" }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "รายงานความปลอดภัยของรหัสผ่าน สุขภาพบัญชี และข้อมูลรั่วไหล เพื่อรักษาตู้นิรภัยให้ปลอดภัย" }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "ความปลอดภัยของบัญชี" }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index d2356eb133f..344b8e03835 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Kayıt arşive gönderildi" }, + "itemWasUnarchived": { + "message": "Kayıt arşivden çıkarıldı" + }, "itemUnarchived": { "message": "Kayıt arşivden çıkarıldı" }, @@ -580,7 +583,13 @@ "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ğine emin misin?" + "message": "Arşivlenmiş kayıtlar genel arama sonuçları ve otomatik doldurma önerilerinden hariç tutulur. Bu kaydı arşivlemek istediğinizden emin misiniz?" + }, + "archived": { + "message": "Arşivlendi" + }, + "unarchiveAndSave": { + "message": "Arşivden çıkar ve kaydet" }, "upgradeToUseArchive": { "message": "Arşivi kullanmak için premium üyelik gereklidir." @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "YubiKey ve Duo gibi marka bazlı iki aşamalı giriş seçenekleri." }, + "premiumSubscriptionEnded": { + "message": "Premium aboneliğiniz sona erdi" + }, + "archivePremiumRestart": { + "message": "Arşivinize yeniden erişebilmek için Premium aboneliğinizi yeniden başlatın. Yeniden başlatmadan önce arşivlenmiş bir kaydın ayrıntılarını düzenlerseniz kayıt tekrar kasanıza taşınır." + }, + "restartPremium": { + "message": "Premium’u yeniden başlat" + }, "ppremiumSignUpReports": { "message": "Kasanızı güvende tutmak için parola hijyeni, hesap sağlığı ve veri ihlali raporları." }, @@ -4815,7 +4833,7 @@ "message": "Yönetici" }, "automaticUserConfirmation": { - "message": "Automatic user confirmation" + "message": "Otomatik kullanıcı onayı" }, "automaticUserConfirmationHint": { "message": "Automatically confirm pending users while this device is unlocked" @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Yeni kullanıcıları otomatik onayla" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Hesap güvenliği" }, @@ -5237,7 +5270,7 @@ "message": "Web sitesi URI'sini yeniden sıralayın. Kayıtı yukarı veya aşağı taşımak için ok tuşunu kullanın." }, "reorderFieldUp": { - "message": "$LABEL$ yukarı taşındı, konum: $LENGTH$'in $INDEX$'i", + "message": "$LABEL$ yukarı taşındı. Konum: $LENGTH$/$INDEX$", "placeholders": { "label": { "content": "$1", @@ -5689,7 +5722,7 @@ "message": "Vulnerable password." }, "changeNow": { - "message": "Change now" + "message": "Şimdi değiştir" }, "missingWebsite": { "message": "Web sitesi eksik" @@ -5766,7 +5799,7 @@ "message": "Oltalama tespiti hakkında daha fazla bilgi edinin" }, "protectedBy": { - "message": "$PRODUCT$ tarafından korunuyor", + "message": "$PRODUCT$ ile korunuyor", "placeholders": { "product": { "content": "$1", diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 854c43872e5..01de2bbadf6 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Запис архівовано" }, + "itemWasUnarchived": { + "message": "Запис розархівовано" + }, "itemUnarchived": { "message": "Запис розархівовано" }, @@ -582,11 +585,17 @@ "archiveItemConfirmDesc": { "message": "Архівовані записи виключаються з результатів звичайного пошуку та пропозицій автозаповнення. Ви дійсно хочете архівувати цей запис?" }, + "archived": { + "message": "Архівовано" + }, + "unarchiveAndSave": { + "message": "Розархівувати й зберегти" + }, "upgradeToUseArchive": { "message": "Для використання архіву необхідна передплата Premium." }, "itemRestored": { - "message": "Item has been restored" + "message": "Запис відновлено" }, "edit": { "message": "Змінити" @@ -1326,19 +1335,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": { @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Додаткові можливості двоетапної авторизації, як-от YubiKey та Duo." }, + "premiumSubscriptionEnded": { + "message": "Ваша передплата Premium завершилась" + }, + "archivePremiumRestart": { + "message": "Щоб відновити доступ до архіву, поновіть передплату Premium. Якщо ви редагуєте архівований запис перед поновленням, його буде повернуто назад у ваше сховище." + }, + "restartPremium": { + "message": "Поновити Premium" + }, "ppremiumSignUpReports": { "message": "Гігієна паролів, здоров'я облікового запису, а також звіти про вразливості даних, щоб зберігати ваше сховище в безпеці." }, @@ -4812,22 +4830,37 @@ "message": "консолі адміністратора," }, "admin": { - "message": "Admin" + "message": "Адміністратор" }, "automaticUserConfirmation": { - "message": "Automatic user confirmation" + "message": "Автоматичне підтвердження користувачів" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "message": "Автоматично підтверджувати користувачів, які перебувають у черзі, поки цей пристрій розблокований" }, "autoConfirmOnboardingCallout": { - "message": "Save time with automatic user confirmation" + "message": "Заощаджуйте час завдяки автоматичному підтвердженню користувачів" }, "autoConfirmWarning": { - "message": "This could impact your organization’s data security. " + "message": "Це може вплинути на безпеку даних вашої організації. " }, "autoConfirmWarningLink": { - "message": "Learn about the risks" + "message": "Дізнатися про ризики" + }, + "autoConfirmSetup": { + "message": "Автоматично підтверджувати нових користувачів" + }, + "autoConfirmSetupDesc": { + "message": "Нові користувачі будуть автоматично підтверджені, якщо пристрій розблоковано." + }, + "autoConfirmSetupHint": { + "message": "Які потенційні ризики безпеки?" + }, + "autoConfirmEnabled": { + "message": "Автоматичне підтвердження увімкнено" + }, + "availableNow": { + "message": "Доступно зараз" }, "accountSecurity": { "message": "Безпека облікового запису" @@ -5686,10 +5719,10 @@ "message": "Цей запис ризикований, і не має адреси вебсайту. Додайте адресу вебсайту і змініть пароль для вдосконалення безпеки." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Вразливий пароль." }, "changeNow": { - "message": "Change now" + "message": "Змінити зараз" }, "missingWebsite": { "message": "Немає вебсайту" @@ -6068,6 +6101,6 @@ "message": "Чому я це бачу?" }, "resizeSideNavigation": { - "message": "Resize side navigation" + "message": "Змінити розмір бічної панелі" } } diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index df7d6c00d7b..4ea7ebf885f 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Mục đã được chuyển vào kho lưu trữ" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Mục đã được bỏ lưu trữ" }, @@ -582,6 +585,12 @@ "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?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "Cần là thành viên cao cấp để sử dụng tính năng Lưu trữ." }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Các tùy chọn xác minh hai bước như YubiKey và Duo." }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "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." }, @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "Learn about the risks" }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Bảo mật tài khoản" }, @@ -4957,7 +4990,7 @@ "message": "Tải xuống Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Tải xuống Bitwarden trên tất cả các thiết bị" + "message": "Tải xuống Bitwarden trên tất cả thiết bị" }, "getTheMobileApp": { "message": "Tải ứng dụng di động" diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 1658ce23944..9530388a4e5 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "项目已发送到归档" }, + "itemWasUnarchived": { + "message": "项目已取消归档" + }, "itemUnarchived": { "message": "项目已取消归档" }, @@ -582,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "需要高级会员才能使用归档。" }, @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "专有的两步登录选项,如 YubiKey 和 Duo。" }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "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." + }, + "restartPremium": { + "message": "Restart Premium" + }, "ppremiumSignUpReports": { "message": "密码健康、账户体检以及数据泄露报告,保障您的密码库安全。" }, @@ -2881,7 +2899,7 @@ "message": "排除域名更改已保存" }, "limitSendViews": { - "message": "查看次数限制" + "message": "限制查看次数" }, "limitSendViewsHint": { "message": "达到限额后,任何人无法查看此 Send。", @@ -2970,7 +2988,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "deleteSendPermanentConfirmation": { - "message": "确定要永久删除这个 Send 吗?", + "message": "确定要永久删除此 Send 吗?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editSend": { @@ -3225,7 +3243,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为「$ACTION$」。", "placeholders": { "hours": { "content": "$1", @@ -4829,6 +4847,21 @@ "autoConfirmWarningLink": { "message": "了解此风险" }, + "autoConfirmSetup": { + "message": "自动确认新用户" + }, + "autoConfirmSetupDesc": { + "message": "当此设备已解锁时,新用户将被自动确认。" + }, + "autoConfirmSetupHint": { + "message": "潜在的安全风险有哪些?" + }, + "autoConfirmEnabled": { + "message": "启用了自动确认" + }, + "availableNow": { + "message": "目前可用" + }, "accountSecurity": { "message": "账户安全" }, diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index 5a772b12476..76407d95621 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "項目已移至封存" }, + "itemWasUnarchived": { + "message": "已取消封存項目" + }, "itemUnarchived": { "message": "項目取消封存" }, @@ -582,11 +585,17 @@ "archiveItemConfirmDesc": { "message": "封存的項目將不會出現在一般搜尋結果或自動填入建議中。確定要封存此項目嗎?" }, + "archived": { + "message": "已封存" + }, + "unarchiveAndSave": { + "message": "取消封存並儲存" + }, "upgradeToUseArchive": { "message": "需要進階版會員才能使用封存功能。" }, "itemRestored": { - "message": "Item has been restored" + "message": "已還原項目" }, "edit": { "message": "編輯" @@ -1536,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "專有的兩步驟登入選項,例如 YubiKey 和 Duo。" }, + "premiumSubscriptionEnded": { + "message": "您的進階版訂閱已到期" + }, + "archivePremiumRestart": { + "message": "若要重新存取您的封存項目,請重新啟用進階版訂閱。若您在重新啟用前編輯封存項目的詳細資料,它將會被移回您的密碼庫。" + }, + "restartPremium": { + "message": "重新啟用進階版" + }, "ppremiumSignUpReports": { "message": "密碼健康度檢查、提供帳戶體檢以及資料外洩報告,以保障您的密碼庫安全。" }, @@ -4812,22 +4830,37 @@ "message": "管理控制台" }, "admin": { - "message": "Admin" + "message": "管理員" }, "automaticUserConfirmation": { - "message": "Automatic user confirmation" + "message": "自動使用者確認" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "message": "在此裝置解鎖時自動確認待處理的使用者" }, "autoConfirmOnboardingCallout": { - "message": "Save time with automatic user confirmation" + "message": "透過自動使用者確認節省時間" }, "autoConfirmWarning": { - "message": "This could impact your organization’s data security. " + "message": "可能影響您的組織資料安全性。" }, "autoConfirmWarningLink": { - "message": "Learn about the risks" + "message": "了解風險" + }, + "autoConfirmSetup": { + "message": "自動確認新使用者" + }, + "autoConfirmSetupDesc": { + "message": "當此裝置處於解鎖狀態時,新的使用者將會自動獲得確認。" + }, + "autoConfirmSetupHint": { + "message": "潛在的安全性風險有哪些?" + }, + "autoConfirmEnabled": { + "message": "開啟自動確認" + }, + "availableNow": { + "message": "立即可用" }, "accountSecurity": { "message": "帳戶安全性" @@ -5686,10 +5719,10 @@ "message": "此登入資訊存在風險,且缺少網站。請新增網站並變更密碼以提升安全性。" }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "有安全疑慮的密碼。" }, "changeNow": { - "message": "Change now" + "message": "立即變更" }, "missingWebsite": { "message": "缺少網站" diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index 86cdbffe059..4f55e68fb41 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Subject, switchMap, timer } from "rxjs"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; @@ -25,7 +23,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set(); private modifyLoginCipherFormData: ModifyLoginCipherFormDataForTab = new Map(); private clearLoginCipherFormDataSubject: Subject = new Subject(); - private notificationFallbackTimeout: number | NodeJS.Timeout | null; + private notificationFallbackTimeout: number | NodeJS.Timeout | null = null; private readonly formSubmissionRequestMethods: Set = new Set(["POST", "PUT", "PATCH"]); private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = { generatedPasswordFilled: ({ message, sender }) => @@ -63,7 +61,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg sender: chrome.runtime.MessageSender, ) { if (await this.shouldInitAddLoginOrChangePasswordNotification(message, sender)) { - this.websiteOriginsWithFields.set(sender.tab.id, this.getSenderUrlMatchPatterns(sender)); + const tabId = sender.tab?.id; + if (tabId === undefined) { + return; + } + this.websiteOriginsWithFields.set(tabId, this.getSenderUrlMatchPatterns(sender)); this.setupWebRequestsListeners(); } } @@ -80,11 +82,16 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg message: OverlayNotificationsExtensionMessage, sender: chrome.runtime.MessageSender, ) { + const tabId = sender.tab?.id; + if (tabId === undefined) { + return false; + } + return ( (await this.isAddLoginOrChangePasswordNotificationEnabled()) && !(await this.isSenderFromExcludedDomain(sender)) && - message.details?.fields?.length > 0 && - !this.websiteOriginsWithFields.has(sender.tab.id) + (message.details?.fields?.length ?? 0) > 0 && + !this.websiteOriginsWithFields.has(tabId) ); } @@ -107,8 +114,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg */ private getSenderUrlMatchPatterns(sender: chrome.runtime.MessageSender) { return new Set([ - ...generateDomainMatchPatterns(sender.url), - ...generateDomainMatchPatterns(sender.tab.url), + ...(sender.url ? generateDomainMatchPatterns(sender.url) : []), + ...(sender.tab?.url ? generateDomainMatchPatterns(sender.tab.url) : []), ]); } @@ -123,7 +130,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg message: OverlayNotificationsExtensionMessage, sender: chrome.runtime.MessageSender, ) => { - if (!this.websiteOriginsWithFields.has(sender.tab.id)) { + const tabId = sender.tab?.id; + if (tabId === undefined || !this.websiteOriginsWithFields.has(tabId)) { return; } @@ -135,25 +143,24 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg this.clearLoginCipherFormDataSubject.next(); const formData = { uri, username, password, newPassword }; - const existingModifyLoginData = this.modifyLoginCipherFormData.get(sender.tab.id); + const existingModifyLoginData = this.modifyLoginCipherFormData.get(tabId); if (existingModifyLoginData) { formData.username = formData.username || existingModifyLoginData.username; formData.password = formData.password || existingModifyLoginData.password; formData.newPassword = formData.newPassword || existingModifyLoginData.newPassword; } - this.modifyLoginCipherFormData.set(sender.tab.id, formData); + this.modifyLoginCipherFormData.set(tabId, formData); this.clearNotificationFallbackTimeout(); - this.notificationFallbackTimeout = setTimeout( - () => - this.setupNotificationInitTrigger( - sender.tab.id, - "", - this.modifyLoginCipherFormData.get(sender.tab.id), - ).catch((error) => this.logService.error(error)), - 1500, - ); + this.notificationFallbackTimeout = setTimeout(() => { + const modifyLoginData = this.modifyLoginCipherFormData.get(tabId); + if (modifyLoginData) { + this.setupNotificationInitTrigger(tabId, "", modifyLoginData).catch((error) => + this.logService.error(error), + ); + } + }, 1500); }; /** @@ -176,6 +183,10 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg private async isSenderFromExcludedDomain(sender: chrome.runtime.MessageSender): Promise { try { const senderOrigin = sender.origin; + if (!senderOrigin) { + return false; + } + const serverConfig = await this.notificationBackground.getActiveUserServerConfig(); const activeUserVault = serverConfig?.environment?.vault; if (activeUserVault === senderOrigin) { @@ -232,11 +243,12 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg details: chrome.webRequest.OnBeforeRequestDetails, ): undefined => { if (this.isPostSubmissionFormRedirection(details)) { - this.setupNotificationInitTrigger( - details.tabId, - details.requestId, - this.modifyLoginCipherFormData.get(details.tabId), - ).catch((error) => this.logService.error(error)); + const modifyLoginData = this.modifyLoginCipherFormData.get(details.tabId); + if (modifyLoginData) { + this.setupNotificationInitTrigger(details.tabId, details.requestId, modifyLoginData).catch( + (error) => this.logService.error(error), + ); + } return; } @@ -385,6 +397,10 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg this.clearNotificationFallbackTimeout(); const tab = await BrowserApi.getTab(tabId); + if (!tab) { + return; + } + if (tab.status !== "complete") { await this.delayNotificationInitUntilTabIsComplete(tabId, requestId, modifyLoginData); return; @@ -410,7 +426,9 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const handleWebNavigationOnCompleted = async () => { chrome.webNavigation.onCompleted.removeListener(handleWebNavigationOnCompleted); const tab = await BrowserApi.getTab(tabId); - await this.processNotifications(requestId, modifyLoginData, tab); + if (tab) { + await this.processNotifications(requestId, modifyLoginData, tab); + } }; chrome.webNavigation.onCompleted.addListener(handleWebNavigationOnCompleted); }; diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts index 61d6b9dc480..5c2b266f829 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts @@ -4,8 +4,10 @@ import { of } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AUTOFILL_ID, + COPY_IDENTIFIER_ID, COPY_PASSWORD_ID, COPY_USERNAME_ID, COPY_VERIFICATION_CODE_ID, @@ -85,6 +87,7 @@ describe("ContextMenuClickedHandler", () => { accountService = mockAccountServiceWith(mockUserId as UserId); totpService = mock(); eventCollectionService = mock(); + userVerificationService = mock(); sut = new ContextMenuClickedHandler( copyToClipboard, @@ -102,6 +105,93 @@ describe("ContextMenuClickedHandler", () => { afterEach(() => jest.resetAllMocks()); describe("run", () => { + beforeEach(() => { + authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked); + userVerificationService.hasMasterPasswordAndMasterKeyHash.mockResolvedValue(false); + }); + + const runWithUrl = (data: chrome.contextMenus.OnClickData) => + sut.run(data, { url: "https://test.com" } as any); + + describe("early returns", () => { + it.each([ + { + name: "tab id is missing", + data: createData(COPY_IDENTIFIER_ID), + tab: { url: "https://test.com" } as any, + expectNotCalled: () => expect(copyToClipboard).not.toHaveBeenCalled(), + }, + { + name: "tab url is missing", + data: createData(`${COPY_USERNAME_ID}_${NOOP_COMMAND_SUFFIX}`, COPY_USERNAME_ID), + tab: {} as any, + expectNotCalled: () => { + expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); + expect(copyToClipboard).not.toHaveBeenCalled(); + }, + }, + ])("returns early when $name", async ({ data, tab, expectNotCalled }) => { + await expect(sut.run(data, tab)).resolves.toBeUndefined(); + expectNotCalled(); + }); + }); + + describe("missing cipher", () => { + it.each([ + { + label: "AUTOFILL", + parentId: AUTOFILL_ID, + extra: () => expect(autofill).not.toHaveBeenCalled(), + }, + { label: "username", parentId: COPY_USERNAME_ID, extra: () => {} }, + { label: "password", parentId: COPY_PASSWORD_ID, extra: () => {} }, + { + label: "totp", + parentId: COPY_VERIFICATION_CODE_ID, + extra: () => expect(totpService.getCode$).not.toHaveBeenCalled(), + }, + ])("breaks silently when cipher is missing for $label", async ({ parentId, extra }) => { + cipherService.getAllDecrypted.mockResolvedValue([]); + + await expect(runWithUrl(createData(`${parentId}_1`, parentId))).resolves.toBeUndefined(); + + expect(copyToClipboard).not.toHaveBeenCalled(); + extra(); + }); + }); + + describe("missing login properties", () => { + it.each([ + { + label: "username", + parentId: COPY_USERNAME_ID, + unset: (c: CipherView): void => (c.login.username = undefined), + }, + { + label: "password", + parentId: COPY_PASSWORD_ID, + unset: (c: CipherView): void => (c.login.password = undefined), + }, + { + label: "totp", + parentId: COPY_VERIFICATION_CODE_ID, + unset: (c: CipherView): void => (c.login.totp = undefined), + isTotp: true, + }, + ])("breaks silently when $label property is missing", async ({ parentId, unset, isTotp }) => { + const cipher = createCipher(); + unset(cipher); + cipherService.getAllDecrypted.mockResolvedValue([cipher]); + + await expect(runWithUrl(createData(`${parentId}_1`, parentId))).resolves.toBeUndefined(); + + expect(copyToClipboard).not.toHaveBeenCalled(); + if (isTotp) { + expect(totpService.getCode$).not.toHaveBeenCalled(); + } + }); + }); + it("can generate password", async () => { await sut.run(createData(GENERATE_PASSWORD_ID), { id: 5 } as any); diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index 6f0979d4fd5..aa01ada0838 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { firstValueFrom } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -72,6 +70,10 @@ export class ContextMenuClickedHandler { await this.generatePasswordToClipboard(tab); break; case COPY_IDENTIFIER_ID: + if (!tab.id) { + return; + } + this.copyToClipboard({ text: await this.getIdentifier(tab, info), tab: tab }); break; default: @@ -120,6 +122,10 @@ export class ContextMenuClickedHandler { if (isCreateCipherAction) { // pass; defer to logic below } else if (menuItemId === NOOP_COMMAND_SUFFIX) { + if (!tab.url) { + return; + } + const additionalCiphersToGet = info.parentMenuItemId === AUTOFILL_IDENTITY_ID ? [CipherType.Identity] @@ -158,6 +164,10 @@ export class ContextMenuClickedHandler { break; } + if (!cipher) { + break; + } + if (await this.isPasswordRepromptRequired(cipher)) { await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, @@ -176,6 +186,10 @@ export class ContextMenuClickedHandler { break; } + if (!cipher || !cipher.login?.username) { + break; + } + this.copyToClipboard({ text: cipher.login.username, tab: tab }); break; case COPY_PASSWORD_ID: @@ -184,6 +198,10 @@ export class ContextMenuClickedHandler { break; } + if (!cipher || !cipher.login?.password) { + break; + } + if (await this.isPasswordRepromptRequired(cipher)) { await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, @@ -205,6 +223,10 @@ export class ContextMenuClickedHandler { break; } + if (!cipher || !cipher.login?.totp) { + break; + } + if (await this.isPasswordRepromptRequired(cipher)) { await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, @@ -240,9 +262,10 @@ export class ContextMenuClickedHandler { } private async getIdentifier(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) { + const tabId = tab.id!; return new Promise((resolve, reject) => { BrowserApi.sendTabsMessage( - tab.id, + tabId, { command: "getClickedElement" }, { frameId: info.frameId }, (identifier: string) => { diff --git a/apps/browser/src/autofill/fido2/utils/webauthn-utils.ts b/apps/browser/src/autofill/fido2/utils/webauthn-utils.ts index 0cccd91876d..07ffa553b07 100644 --- a/apps/browser/src/autofill/fido2/utils/webauthn-utils.ts +++ b/apps/browser/src/autofill/fido2/utils/webauthn-utils.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CreateCredentialResult, AssertCredentialResult, diff --git a/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts b/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts index adabae2c31d..ace314c6a84 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2-cipher-row.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy } from "@angular/core"; @@ -33,13 +31,10 @@ export class Fido2CipherRowComponent { @Output() onSelected = new EventEmitter(); // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() cipher: CipherView; + @Input({ required: true }) cipher!: CipherView; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() last: boolean; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() title: string; + @Input({ required: true }) title!: string; protected selectCipher(c: CipherView) { this.onSelected.emit(c); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index b9b41943b04..9d551ec2622 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1510,6 +1510,7 @@ export default class MainBackground { this.accountService, this.billingAccountProfileStateService, this.configService, + this.logService, this.organizationService, this.platformUtilsService, this.stateProvider, diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts index 30aa947092d..746f5a1f8f7 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts @@ -9,7 +9,66 @@ import { import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; import { LogService } from "@bitwarden/logging"; -import { PhishingDataService, PhishingData, PHISHING_DOMAINS_KEY } from "./phishing-data.service"; +import { + PhishingDataService, + PHISHING_DOMAINS_META_KEY, + PHISHING_DOMAINS_BLOB_KEY, + PhishingDataMeta, + PhishingDataBlob, +} from "./phishing-data.service"; + +const flushPromises = () => + 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); + }; +}; describe("PhishingDataService", () => { let service: PhishingDataService; @@ -17,17 +76,30 @@ describe("PhishingDataService", () => { let taskSchedulerService: TaskSchedulerService; let logService: MockProxy; let platformUtilsService: MockProxy; - const stateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider(); + const fakeGlobalStateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider(); - const setMockState = (state: PhishingData) => { - stateProvider.getFake(PHISHING_DOMAINS_KEY).stateSubject.next(state); + 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 fetchWebAddressesSpy: jest.SpyInstance; + let fetchAndCompressSpy: jest.SpyInstance; - beforeEach(() => { + 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(); apiService = mock(); logService = mock(); @@ -40,54 +112,75 @@ describe("PhishingDataService", () => { service = new PhishingDataService( apiService, taskSchedulerService, - stateProvider, + fakeGlobalStateProvider, logService, platformUtilsService, ); - fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum"); - fetchWebAddressesSpy = jest.spyOn(service as any, "fetchPhishingWebAddresses"); + fetchAndCompressSpy = jest.spyOn(service as any, "fetchAndCompress"); + + 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 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"); + expect(await service.isPhishingWebAddress(url)).toBe(true); + expect(await service.isPhishingWebAddress(QAurl)).toBe(true); + + sub.unsubscribe(); + }); }); 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", async () => { - setMockState({ - webAddresses: ["phish.com", "badguy.net"], - timestamp: Date.now(), - checksum: "abc123", - applicationVersion: "1.0.0", - }); + service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); + const url = new URL("http://phish.com"); const result = await service.isPhishingWebAddress(url); + expect(result).toBe(true); }); it("should not detect a safe web address", async () => { - setMockState({ - webAddresses: ["phish.com", "badguy.net"], - timestamp: Date.now(), - checksum: "abc123", - applicationVersion: "1.0.0", - }); + service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); const url = new URL("http://safe.com"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(false); }); it("should match against root web address", async () => { - setMockState({ - webAddresses: ["phish.com", "badguy.net"], - timestamp: Date.now(), - checksum: "abc123", - applicationVersion: "1.0.0", - }); + service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); const url = new URL("http://phish.com/about"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(true); }); it("should not error on empty state", async () => { - setMockState(undefined as any); + service["_webAddressesSet"] = null; const url = new URL("http://phish.com/about"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(false); @@ -95,64 +188,142 @@ describe("PhishingDataService", () => { }); describe("getNextWebAddresses", () => { + beforeEach(() => { + jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob); + jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob); + }); + it("refetches all web addresses if applicationVersion has changed", async () => { - const prev: PhishingData = { - webAddresses: ["a.com"], + const prev: PhishingDataMeta = { timestamp: Date.now() - 60000, checksum: "old", applicationVersion: "1.0.0", }; fetchChecksumSpy.mockResolvedValue("new"); - fetchWebAddressesSpy.mockResolvedValue(["d.com", "e.com"]); platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0"); const result = await service.getNextWebAddresses(prev); - expect(result!.webAddresses).toEqual(["d.com", "e.com"]); - expect(result!.checksum).toBe("new"); - expect(result!.applicationVersion).toBe("2.0.0"); + expect(result!.blob).toBe("compressed-blob"); + expect(result!.meta!.checksum).toBe("new"); + expect(result!.meta!.applicationVersion).toBe("2.0.0"); }); - it("only updates timestamp if checksum matches", async () => { - const prev: PhishingData = { - webAddresses: ["a.com"], - timestamp: Date.now() - 60000, + 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!.webAddresses).toEqual(prev.webAddresses); - expect(result!.checksum).toBe("abc"); - expect(result!.timestamp).not.toBe(prev.timestamp); + expect(result).toBeNull(); }); - it("patches daily domains if cache is fresh", async () => { - const prev: PhishingData = { - webAddresses: ["a.com"], - timestamp: Date.now() - 60000, + 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"); - fetchWebAddressesSpy.mockResolvedValue(["b.com", "c.com"]); + fetchAndCompressSpy.mockResolvedValue("new-blob"); const result = await service.getNextWebAddresses(prev); - expect(result!.webAddresses).toEqual(["a.com", "b.com", "c.com"]); - expect(result!.checksum).toBe("new"); + expect(result!.blob).toBe("new-blob"); + expect(result!.meta!.checksum).toBe("new"); + }); + }); + + describe("compression helpers", () => { + let restore: () => void; + + beforeEach(async () => { + restore = setupPhishingMocks("abc"); }); - it("fetches all domains if cache is old", async () => { - const prev: PhishingData = { - webAddresses: ["a.com"], - timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000, - checksum: "old", - applicationVersion: "1.0.0", - }; - fetchChecksumSpy.mockResolvedValue("new"); - fetchWebAddressesSpy.mockResolvedValue(["d.com", "e.com"]); - const result = await service.getNextWebAddresses(prev); - expect(result!.webAddresses).toEqual(["d.com", "e.com"]); - expect(result!.checksum).toBe("new"); + afterEach(() => { + if (restore) { + restore(); + } + delete (Uint8Array as any).fromBase64; + jest.restoreAllMocks(); + }); + + describe("_compressString", () => { + it("compresses a string to base64", async () => { + const out = await service["_compressString"]("abc"); + expect(out).toBe("YWJj"); // base64 for 'abc' + }); + + 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); + }); + + 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); + }); + }); + 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]); + + const out = await service["_decompressString"]("ignored"); + expect(out).toBe("abc"); + }); + + it("decompresses using atob on error", async () => { + const base64 = btoa(encodeURIComponent("abc")); + const out = await service["_decompressString"](base64); + expect(out).toBe("abc"); + }); + }); + }); + + describe("_loadBlobToMemory", () => { + it("loads blob into memory set", async () => { + const prevBlob = "ignored-base64"; + fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_BLOB_KEY).stateSubject.next(prevBlob); + + jest.spyOn(service as any, "_decompressString").mockResolvedValue("phish.com\nbadguy.net"); + + await service["_loadBlobToMemory"](); + const set = service["_webAddressesSet"] as Set; + expect(set).toBeDefined(); + expect(set.has("phish.com")).toBe(true); + expect(set.has("badguy.net")).toBe(true); }); }); }); 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 21fe74f1873..85e91b06a6b 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 @@ -3,14 +3,11 @@ import { EMPTY, first, firstValueFrom, - map, - retry, share, startWith, Subject, switchMap, tap, - timer, } from "rxjs"; import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags"; @@ -22,11 +19,14 @@ import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bi import { getPhishingResources, PhishingResourceType } from "../phishing-resources"; -export type PhishingData = { - webAddresses: string[]; - timestamp: number; +/** + * Metadata about the phishing data set + */ +export type PhishingDataMeta = { + /** The last known checksum of the phishing data set */ checksum: string; - + /** The last time the data set was updated */ + timestamp: number; /** * We store the application version to refetch the entire dataset on a new client release. * This counteracts daily appends updates not removing inactive or false positive web addresses. @@ -34,30 +34,42 @@ export type PhishingData = { applicationVersion: string; }; -export const PHISHING_DOMAINS_KEY = new KeyDefinition( +/** + * The phishing data blob is a string representation of the phishing web addresses + */ +export type PhishingDataBlob = string; +export type PhishingData = { meta: PhishingDataMeta; blob: PhishingDataBlob }; + +export const PHISHING_DOMAINS_META_KEY = new KeyDefinition( PHISHING_DETECTION_DISK, - "phishingDomains", + "phishingDomainsMeta", { - deserializer: (value: PhishingData) => - value ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" }, + deserializer: (value: PhishingDataMeta) => { + return { + checksum: value?.checksum ?? "", + timestamp: value?.timestamp ?? 0, + applicationVersion: value?.applicationVersion ?? "", + }; + }, + }, +); + +export const PHISHING_DOMAINS_BLOB_KEY = new KeyDefinition( + PHISHING_DETECTION_DISK, + "phishingDomainsBlob", + { + deserializer: (value: string) => value ?? "", }, ); /** Coordinates fetching, caching, and patching of known phishing web addresses */ export class PhishingDataService { - private _testWebAddresses = this.getTestWebAddresses(); - private _cachedState = this.globalStateProvider.get(PHISHING_DOMAINS_KEY); - private _webAddresses$ = this._cachedState.state$.pipe( - map( - (state) => - new Set( - (state?.webAddresses?.filter((line) => line.trim().length > 0) ?? []).concat( - this._testWebAddresses, - "phishing.testcategory.com", // Included for QA to test in prod - ), - ), - ), - ); + private _testWebAddresses = this.getTestWebAddresses().concat("phishing.testcategory.com"); // Included for QA to test in prod + 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; // How often are new web addresses added to the remote? readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours @@ -65,39 +77,17 @@ export class PhishingDataService { private _triggerUpdate$ = new Subject(); update$ = this._triggerUpdate$.pipe( startWith(undefined), // Always emit once - tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)), switchMap(() => - this._cachedState.state$.pipe( + this._phishingMetaState.state$.pipe( first(), // Only take the first value to avoid an infinite loop when updating the cache below - switchMap(async (cachedState) => { - const next = await this.getNextWebAddresses(cachedState); - if (next) { - await this._cachedState.update(() => next); - this.logService.info(`[PhishingDataService] cache updated`); - } + tap((metaState) => { + // Perform any updates in the background if needed + void this._backgroundUpdate(metaState); }), - retry({ - count: 3, - delay: (err, count) => { - this.logService.error( - `[PhishingDataService] Unable to update web addresses. Attempt ${count}.`, - err, - ); - return timer(5 * 60 * 1000); // 5 minutes - }, - resetOnSuccess: true, + catchError((err: unknown) => { + this.logService.error("[PhishingDataService] Background update failed to start.", err); + return EMPTY; }), - catchError( - ( - err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */, - ) => { - this.logService.error( - "[PhishingDataService] Retries unsuccessful. Unable to update web addresses.", - err, - ); - return EMPTY; - }, - ), ), ), share(), @@ -111,6 +101,7 @@ export class PhishingDataService { private platformUtilsService: PlatformUtilsService, private resourceType: PhishingResourceType = PhishingResourceType.Links, ) { + this.logService.debug("[PhishingDataService] Initializing service..."); this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => { this._triggerUpdate$.next(); }); @@ -118,6 +109,7 @@ export class PhishingDataService { ScheduledTaskNames.phishingDomainUpdate, this.UPDATE_INTERVAL_DURATION, ); + void this._loadBlobToMemory(); } /** @@ -127,12 +119,17 @@ export class PhishingDataService { * @returns True if the URL is a known phishing web address, false otherwise */ async isPhishingWebAddress(url: URL): Promise { - // Use domain (hostname) matching for domain resources, and link matching for links resources - const entries = await firstValueFrom(this._webAddresses$); + if (!this._webAddressesSet) { + this.logService.debug("[PhishingDataService] Set not loaded; skipping check"); + return false; + } + const set = this._webAddressesSet!; const resource = getPhishingResources(this.resourceType); - if (resource && resource.match) { - for (const entry of entries) { + + // Custom matcher per resource + if (resource && resource?.match) { + for (const entry of set) { if (resource.match(url, entry)) { return true; } @@ -140,54 +137,59 @@ export class PhishingDataService { return false; } - // Default/domain behavior: exact hostname match as a fallback - return entries.has(url.hostname); + // Default set-based lookup + return set.has(url.hostname); } - async getNextWebAddresses(prev: PhishingData | null): Promise { - prev = prev ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" }; - const timestamp = Date.now(); - const prevAge = timestamp - prev.timestamp; - this.logService.info(`[PhishingDataService] Cache age: ${prevAge}`); + 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(); - - // If checksum matches, return existing data with new timestamp & version const remoteChecksum = await this.fetchPhishingChecksum(this.resourceType); - if (remoteChecksum && prev.checksum === remoteChecksum) { - this.logService.info( - `[PhishingDataService] Remote checksum matches local checksum, updating timestamp only.`, - ); - return { ...prev, timestamp, applicationVersion }; - } - // Checksum is different, data needs to be updated. - // Approach 1: Fetch only new web addresses and append - const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION; - if (isOneDayOldMax && applicationVersion === prev.applicationVersion) { - const webAddressesTodayUrl = getPhishingResources(this.resourceType)!.todayUrl; - const dailyWebAddresses: string[] = - await this.fetchPhishingWebAddresses(webAddressesTodayUrl); - this.logService.info( - `[PhishingDataService] ${dailyWebAddresses.length} new phishing web addresses added`, - ); + // 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 { - webAddresses: prev.webAddresses.concat(dailyWebAddresses), - checksum: remoteChecksum, - timestamp, - applicationVersion, + blob, + meta: { checksum: remoteChecksum, timestamp: now, applicationVersion }, }; } - // Approach 2: Fetch all web addresses - const remoteUrl = getPhishingResources(this.resourceType)!.remoteUrl; - const remoteWebAddresses = await this.fetchPhishingWebAddresses(remoteUrl); - return { - webAddresses: remoteWebAddresses, - timestamp, - checksum: remoteChecksum, - 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; } private async fetchPhishingChecksum(type: PhishingResourceType = PhishingResourceType.Domains) { @@ -198,8 +200,24 @@ export class PhishingDataService { } 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"); + } - private async fetchPhishingWebAddresses(url: string) { + 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); + + // Return as Base64 for storage + return (bytes as any).toBase64 ? (bytes as any).toBase64() : this._uint8ToBase64Fallback(bytes); + } + + private async fetchText(url: string) { const response = await this.apiService.nativeFetch(new Request(url)); if (!response.ok) { @@ -225,4 +243,136 @@ export class PhishingDataService { } return []; } + + // 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 + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const next = await this.getNextWebAddresses(phishingMeta); + if (!next) { + return; // No update needed + } + + if (next.meta) { + await this._phishingMetaState.update(() => next!.meta!); + } + if (next.blob) { + await this._phishingBlobState.update(() => next!.blob!); + await this._loadBlobToMemory(); + } + + // 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, + ); + } + } + } + } + + // [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)); + } + } + + // Try to load compressed newline blob into an in-memory Set for fast lookups + private async _loadBlobToMemory(): Promise { + this.logService.debug("[PhishingDataService] Loading data blob into memory..."); + try { + const blobBase64 = await firstValueFrom(this._phishingBlobState.state$); + if (!blobBase64) { + return; + } + + const text = await this._decompressString(blobBase64); + // Split and filter + const lines = text.split(/\r?\n/); + const newWebAddressesSet = new Set(lines); + + // Add test addresses + 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`, + ); + } catch (err) { + this.logService.error("[PhishingDataService] Failed to load blob into memory", err); + } + } + 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)); + } } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index c462e798a42..06a021085ea 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -537,6 +537,7 @@ const safeProviders: SafeProvider[] = [ AccountService, BillingAccountProfileStateService, ConfigService, + LogService, OrganizationService, PlatformUtilsService, StateProvider, diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts index 8f30d00cc31..f180564b912 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -9,9 +9,9 @@ import { map, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts index 1a3df238543..521d72bba0c 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts @@ -11,9 +11,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, I18nMockService, IconModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; diff --git a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts index 1f0d9f2a0c9..ddf50eb39bf 100644 --- a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, input, OnInit } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { CenterPositionStrategy, DialogService } from "@bitwarden/components"; import { SendFormConfig } from "@bitwarden/send-ui"; 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 6e73d9811f2..dfbfabf8d5e 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 @@ -17,10 +17,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { ButtonModule, NoItemsModule } from "@bitwarden/components"; import { 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 89769bdd1ce..f36a475a805 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 @@ -13,7 +13,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv 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/enums/send-type"; +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"; import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator"; @@ -139,7 +139,7 @@ export class SendV2Component implements OnDestroy { .pipe(takeUntilDestroyed()) .subscribe(([emptyList, noFilteredResults, currentFilter]) => { if (currentFilter?.sendType !== null) { - this.title = this.sendTypeTitles[currentFilter.sendType] ?? "allSends"; + this.title = this.sendTypeTitles[currentFilter.sendType as SendType] ?? "allSends"; } else { this.title = "allSends"; } diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html index 8f184c6a0c1..8b4d2d21b8b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html @@ -1,3 +1,5 @@ +@let previouslyCouldArchive = !(userCanArchive$ | async) && config?.originalCipher?.archivedDate; + + @if (config?.originalCipher?.archivedDate) { + + + {{ "archived" | i18n }} + + + } @@ -24,21 +33,41 @@ - + + @if (isEditMode) { + @if ((archiveFlagEnabled$ | async) && isCipherArchived) { + + } + @if ((userCanArchive$ | async) && canCipherBeArchived) { + + } + } + @if (canDeleteCipher$ | async) { + + } + diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts index f2c9d470816..8ea23e7e2b9 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts @@ -1,10 +1,15 @@ +import { Location } from "@angular/common"; import { ComponentFixture, fakeAsync, TestBed, 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 { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; +import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { EventType } from "@bitwarden/common/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -12,13 +17,17 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { TaskService } from "@bitwarden/common/vault/tasks"; import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info"; +import { DialogService } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, CipherFormConfig, CipherFormConfigService, CipherFormMode, @@ -45,15 +54,17 @@ describe("AddEditV2Component", () => { let cipherServiceMock: MockProxy; const buildConfigResponse = { originalCipher: {} } as CipherFormConfig; - const buildConfig = jest.fn((mode: CipherFormMode) => - Promise.resolve({ ...buildConfigResponse, mode }), - ); + const buildConfig = jest.fn((mode) => Promise.resolve({ ...buildConfigResponse, mode })); const queryParams$ = new BehaviorSubject({}); const disable = jest.fn(); const navigate = jest.fn(); const back = jest.fn().mockResolvedValue(null); const setHistory = jest.fn(); const collect = jest.fn().mockResolvedValue(null); + const history$ = jest.fn(); + const historyGo = jest.fn().mockResolvedValue(null); + const openSimpleDialog = jest.fn().mockResolvedValue(true); + const cipherArchiveService = mock(); beforeEach(async () => { buildConfig.mockClear(); @@ -61,6 +72,12 @@ describe("AddEditV2Component", () => { navigate.mockClear(); back.mockClear(); collect.mockClear(); + history$.mockClear(); + historyGo.mockClear(); + openSimpleDialog.mockClear(); + + cipherArchiveService.hasArchiveFlagEnabled$ = of(true); + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); addEditCipherInfo$ = new BehaviorSubject(null); cipherServiceMock = mock({ @@ -70,11 +87,13 @@ describe("AddEditV2Component", () => { await TestBed.configureTestingModule({ imports: [AddEditV2Component], providers: [ + provideNoopAnimations(), { provide: PlatformUtilsService, useValue: mock() }, { provide: ConfigService, useValue: mock() }, - { provide: PopupRouterCacheService, useValue: { back, setHistory } }, + { provide: PopupRouterCacheService, useValue: { back, setHistory, history$ } }, { provide: PopupCloseWarningService, useValue: { disable } }, { provide: Router, useValue: { navigate } }, + { provide: Location, useValue: { historyGo } }, { provide: ActivatedRoute, useValue: { queryParams: queryParams$ } }, { provide: I18nService, useValue: { t: (key: string) => key } }, { provide: CipherService, useValue: cipherServiceMock }, @@ -83,10 +102,33 @@ describe("AddEditV2Component", () => { { provide: CipherAuthorizationService, useValue: { - canDeleteCipher$: jest.fn().mockReturnValue(true), + canDeleteCipher$: jest.fn().mockReturnValue(of(true)), }, }, { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) }, + { + provide: TaskService, + useValue: mock(), + }, + { + provide: ViewCacheService, + useValue: { signal: jest.fn(() => (): any => null) }, + }, + { + provide: BillingAccountProfileStateService, + useValue: mock(), + }, + { + provide: CipherArchiveService, + useValue: cipherArchiveService, + }, + { + provide: ArchiveCipherUtilitiesService, + useValue: { + archiveCipher: jest.fn().mockResolvedValue(null), + unarchiveCipher: jest.fn().mockResolvedValue(null), + }, + }, ], }) .overrideProvider(CipherFormConfigService, { @@ -94,6 +136,11 @@ describe("AddEditV2Component", () => { buildConfig, }, }) + .overrideProvider(DialogService, { + useValue: { + openSimpleDialog, + }, + }) .compileComponents(); fixture = TestBed.createComponent(AddEditV2Component); @@ -356,6 +403,151 @@ describe("AddEditV2Component", () => { }); }); + describe("submit button text", () => { + beforeEach(() => { + // prevent form from rendering + jest.spyOn(component as any, "loading", "get").mockReturnValue(true); + }); + + it("sets it to 'save' by default", fakeAsync(() => { + buildConfigResponse.originalCipher = {} as Cipher; + + queryParams$.next({}); + + tick(); + + const submitBtn = fixture.debugElement.query(By.css("button[type=submit]")); + expect(submitBtn.nativeElement.textContent.trim()).toBe("save"); + })); + + it("sets it to 'save' when the user is able to archive the item", fakeAsync(() => { + buildConfigResponse.originalCipher = { isArchived: false } as any; + + queryParams$.next({}); + + tick(); + + const submitBtn = fixture.debugElement.query(By.css("button[type=submit]")); + expect(submitBtn.nativeElement.textContent.trim()).toBe("save"); + })); + + it("sets it to 'unarchiveAndSave' when the user cannot archive and the item is archived", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + buildConfigResponse.originalCipher = { isArchived: true } as any; + + queryParams$.next({}); + tick(); + + const submitBtn = fixture.debugElement.query(By.css("button[type=submit]")); + expect(submitBtn.nativeElement.textContent.trim()).toBe("save"); + })); + }); + + describe("archive", () => { + it("calls archiveCipherUtilsService service to archive the cipher", async () => { + buildConfigResponse.originalCipher = { id: "222-333-444-5555", edit: true } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + await fixture.whenStable(); + await component.archive(); + + expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "222-333-444-5555" }), + true, + ); + }); + }); + + describe("unarchive", () => { + it("calls archiveCipherUtilsService service to unarchive the cipher", async () => { + buildConfigResponse.originalCipher = { + id: "222-333-444-5555", + archivedDate: new Date(), + edit: true, + } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + await component.unarchive(); + + expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "222-333-444-5555" }), + ); + }); + }); + + describe("archive button", () => { + beforeEach(() => { + // prevent form from rendering + jest.spyOn(component as any, "loading", "get").mockReturnValue(true); + buildConfigResponse.originalCipher = { archivedDate: undefined, edit: true } as Cipher; + }); + + it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + queryParams$.next({ cipherId: "222-333-444-5555" }); + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeTruthy(); + })); + + it("does not show the archive button when the user cannot archive", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + + it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + buildConfigResponse.originalCipher = { archivedDate: new Date(), edit: true } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + }); + + describe("unarchive button", () => { + beforeEach(() => { + // prevent form from rendering + jest.spyOn(component as any, "loading", "get").mockReturnValue(true); + buildConfigResponse.originalCipher = { edit: true } as Cipher; + }); + + it("shows the unarchive button when the cipher is archived", fakeAsync(() => { + buildConfigResponse.originalCipher = { archivedDate: new Date(), edit: true } as Cipher; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeTruthy(); + })); + + it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => { + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeFalsy(); + })); + }); + describe("delete", () => { it("dialogService openSimpleDialog called when deleteBtn is hit", async () => { const dialogSpy = jest @@ -374,12 +566,104 @@ describe("AddEditV2Component", () => { expect(deleteCipherSpy).toHaveBeenCalled(); }); - it("navigates to vault tab after deletion", async () => { + it("navigates to vault tab after deletion by default", async () => { jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true); await component.delete(); expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]); }); + + it("navigates to custom route when not in history", fakeAsync(() => { + buildConfigResponse.originalCipher = { edit: true, id: "123" } as Cipher; + queryParams$.next({ + cipherId: "123", + routeAfterDeletion: "/archive", + }); + + tick(); + + // Mock history without the target route + history$.mockReturnValue( + of([ + { url: "/tabs/vault" }, + { url: "/view-cipher?cipherId=123" }, + { url: "/add-edit?cipherId=123" }, + ]), + ); + + jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true); + + void component.delete(); + tick(); + + expect(history$).toHaveBeenCalled(); + expect(historyGo).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(["/archive"]); + })); + + it("uses historyGo when custom route exists in history", fakeAsync(() => { + buildConfigResponse.originalCipher = { edit: true, id: "123" } as Cipher; + queryParams$.next({ + cipherId: "123", + routeAfterDeletion: "/archive", + }); + + tick(); + + history$.mockReturnValue( + of([ + { url: "/tabs/vault" }, + { url: "/archive" }, + { url: "/view-cipher?cipherId=123" }, + { url: "/add-edit?cipherId=123" }, + ]), + ); + + jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true); + + void component.delete(); + tick(); + + expect(history$).toHaveBeenCalled(); + expect(historyGo).toHaveBeenCalledWith(-2); + expect(navigate).not.toHaveBeenCalled(); + })); + + it("uses router.navigate for default /tabs/vault route", fakeAsync(() => { + buildConfigResponse.originalCipher = { edit: true, id: "456" } as Cipher; + component.routeAfterDeletion = "/tabs/vault"; + + queryParams$.next({ + cipherId: "456", + }); + + tick(); + + jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true); + + void component.delete(); + tick(); + + expect(history$).not.toHaveBeenCalled(); + expect(historyGo).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]); + })); + + it("ignores invalid routeAfterDeletion query param and uses default route", fakeAsync(() => { + // Reset the component's routeAfterDeletion to default before this test + component.routeAfterDeletion = "/tabs/vault"; + + buildConfigResponse.originalCipher = { edit: true, id: "456" } as Cipher; + queryParams$.next({ + cipherId: "456", + routeAfterDeletion: "/invalid/route", + }); + + tick(); + + // The invalid route should be ignored, routeAfterDeletion should remain default + expect(component.routeAfterDeletion).toBe("/tabs/vault"); + })); }); describe("reloadAddEditCipherData", () => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index 22aad854dd0..895a5fe0cce 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { CommonModule } from "@angular/common"; -import { Component, OnInit, OnDestroy } from "@angular/core"; +import { CommonModule, Location } from "@angular/common"; +import { Component, OnInit, OnDestroy, viewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; import { ActivatedRoute, Params, Router } from "@angular/router"; @@ -16,6 +16,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; @@ -29,8 +30,11 @@ import { IconButtonModule, DialogService, ToastService, + BadgeModule, } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, + CipherFormComponent, CipherFormConfig, CipherFormConfigService, CipherFormGenerationService, @@ -60,6 +64,18 @@ import { import { VaultPopoutType } from "../../../utils/vault-popout-window"; import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.component"; +/** + * Available routes to navigate to after editing a cipher. + * Useful when the user could be coming from a different view other than the main vault (e.g., archive). + */ +export const ROUTES_AFTER_EDIT_DELETION = Object.freeze({ + tabsVault: "/tabs/vault", + archive: "/archive", +} as const); + +export type ROUTES_AFTER_EDIT_DELETION = + (typeof ROUTES_AFTER_EDIT_DELETION)[keyof typeof ROUTES_AFTER_EDIT_DELETION]; + /** * Helper class to parse query parameters for the AddEdit route. */ @@ -75,6 +91,7 @@ class QueryParams { this.username = params.username; this.name = params.name; this.prefillNameAndURIFromTab = params.prefillNameAndURIFromTab; + this.routeAfterDeletion = params.routeAfterDeletion ?? ROUTES_AFTER_EDIT_DELETION.tabsVault; } /** @@ -127,6 +144,12 @@ class QueryParams { * NOTE: This will override the `uri` and `name` query parameters if set to true. */ prefillNameAndURIFromTab?: true; + + /** + * The view that will be navigated to after deleting the cipher. + * @default "/tabs/vault" + */ + routeAfterDeletion?: ROUTES_AFTER_EDIT_DELETION; } export type AddEditQueryParams = Partial>; @@ -156,12 +179,15 @@ export type AddEditQueryParams = Partial>; AsyncActionsModule, PopOutComponent, IconButtonModule, + BadgeModule, ], }) export class AddEditV2Component implements OnInit, OnDestroy { + readonly cipherFormComponent = viewChild(CipherFormComponent); headerText: string; config: CipherFormConfig; canDeleteCipher$: Observable; + routeAfterDeletion: ROUTES_AFTER_EDIT_DELETION = "/tabs/vault"; get loading() { return this.config == null; @@ -171,9 +197,26 @@ export class AddEditV2Component implements OnInit, OnDestroy { return this.config?.originalCipher?.id as CipherId; } + get cipher(): CipherView { + return new CipherView(this.config?.originalCipher); + } + + get canCipherBeArchived(): boolean { + return this.cipher?.canBeArchived; + } + + get isCipherArchived(): boolean { + return this.cipher?.isArchived; + } + private fido2PopoutSessionData$ = fido2PopoutSessionData$(); private fido2PopoutSessionData: Fido2SessionData; + protected userId$ = this.accountService.activeAccount$.pipe(getUserId); + protected userCanArchive$ = this.userId$.pipe( + switchMap((userId) => this.archiveService.userCanArchive$(userId)), + ); + private get inFido2PopoutWindow() { return BrowserPopupUtils.inPopout(window) && this.fido2PopoutSessionData.isFido2Session; } @@ -182,6 +225,8 @@ export class AddEditV2Component implements OnInit, OnDestroy { return BrowserPopupUtils.inSingleActionPopout(window, VaultPopoutType.addEditVaultItem); } + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; + constructor( private route: ActivatedRoute, private i18nService: I18nService, @@ -196,6 +241,9 @@ export class AddEditV2Component implements OnInit, OnDestroy { private dialogService: DialogService, protected cipherAuthorizationService: CipherAuthorizationService, private accountService: AccountService, + private location: Location, + private archiveService: CipherArchiveService, + private archiveCipherUtilsService: ArchiveCipherUtilitiesService, ) { this.subscribeToParams(); } @@ -322,6 +370,10 @@ export class AddEditV2Component implements OnInit, OnDestroy { await BrowserApi.sendMessage("addEditCipherSubmitted"); } + get isEditMode(): boolean { + return ["edit", "partial-edit"].includes(this.config?.mode); + } + subscribeToParams(): void { this.route.queryParams .pipe( @@ -345,9 +397,7 @@ export class AddEditV2Component implements OnInit, OnDestroy { } config.initialValues = await this.setInitialValuesFromParams(params); - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(getUserId), - ); + const activeUserId = await firstValueFrom(this.userId$); // The browser notification bar and overlay use addEditCipherInfo$ to pass modified cipher details to the form // Attempt to fetch them here and overwrite the initialValues if present @@ -378,6 +428,13 @@ export class AddEditV2Component implements OnInit, OnDestroy { ); } + if ( + params.routeAfterDeletion && + Object.values(ROUTES_AFTER_EDIT_DELETION).includes(params.routeAfterDeletion) + ) { + this.routeAfterDeletion = params.routeAfterDeletion; + } + return config; }), ) @@ -430,6 +487,40 @@ export class AddEditV2Component implements OnInit, OnDestroy { return this.i18nService.t(translation[type]); } + /** + * Update the cipher in the form after archiving/unarchiving. + * @param revisionDate The new revision date. + * @param archivedDate The new archived date (null if unarchived). + **/ + updateCipherFromArchive = (revisionDate: Date, archivedDate: Date | null) => { + this.cipherFormComponent().patchCipher((current) => { + current.revisionDate = revisionDate; + current.archivedDate = archivedDate; + return current; + }); + }; + + archive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true); + + if (!cipherResponse) { + return; + } + this.updateCipherFromArchive( + new Date(cipherResponse.revisionDate), + new Date(cipherResponse.archivedDate), + ); + }; + + unarchive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher); + + if (!cipherResponse) { + return; + } + this.updateCipherFromArchive(new Date(cipherResponse.revisionDate), null); + }; + delete = async () => { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "deleteItem" }, @@ -444,14 +535,28 @@ export class AddEditV2Component implements OnInit, OnDestroy { } try { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const activeUserId = await firstValueFrom(this.userId$); await this.deleteCipher(activeUserId); } catch (e) { this.logService.error(e); return false; } - await this.router.navigate(["/tabs/vault"]); + if (this.routeAfterDeletion !== ROUTES_AFTER_EDIT_DELETION.tabsVault) { + const history = await firstValueFrom(this.popupRouterCacheService.history$()); + const targetIndex = history.map((h) => h.url).lastIndexOf(this.routeAfterDeletion); + + if (targetIndex !== -1) { + const stepsBack = targetIndex - (history.length - 1); + // Use historyGo to navigate back to the target route in history + // This allows downstream calls to `back()` to continue working as expected + await this.location.historyGo(stepsBack); + } else { + await this.router.navigate([this.routeAfterDeletion]); + } + } else { + await this.router.navigate([this.routeAfterDeletion]); + } this.toastService.showToast({ variant: "success", diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index b86ec24fd20..be67869d3df 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -3,62 +3,63 @@ type="button" bitIconButton="bwi-ellipsis-v" size="small" - [label]="'moreOptionsLabel' | i18n: cipher.name" - [disabled]="decryptionFailure" + [label]="'moreOptionsLabelNoPlaceholder' | i18n" [bitMenuTriggerFor]="moreOptions" > - - - + + + + - - - - - - @if (canEdit) { - - } - - - {{ "clone" | i18n }} - - - {{ "assignToCollections" | i18n }} - - - @if (showArchive$ | async) { - @if (canArchive$ | async) { - - } @else { - + } @else { + + + } } } @if (canDelete$ | async) { diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts index bd9ce108522..b999d8db35a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -158,14 +158,6 @@ describe("ItemMoreOptionsComponent", () => { expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); }); - it("does not show the exact match dialog when the default match strategy is Exact and autofill confirmation is not to be shown", async () => { - uriMatchStrategy$.next(UriMatchStrategy.Exact); - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - }); - describe("autofill confirmation dialog", () => { beforeEach(() => { uriMatchStrategy$.next(UriMatchStrategy.Domain); @@ -236,22 +228,30 @@ describe("ItemMoreOptionsComponent", () => { }); describe("URI match strategy handling", () => { + it("calls the passwordService to passwordRepromptCheck", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); + }); + describe("when the default URI match strategy is Exact", () => { beforeEach(() => { uriMatchStrategy$.next(UriMatchStrategy.Exact); }); - it("calls the passwordService to passwordRepromptCheck", async () => { - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); - mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); - - await component.doAutofill(); - - expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); - }); - - it("shows the exact match dialog", async () => { + it("shows the exact match dialog when the cipher has no saved URIs", async () => { autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [], + }, + })); await component.doAutofill(); @@ -266,6 +266,53 @@ describe("ItemMoreOptionsComponent", () => { expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); }); + + it("does not show the exact match dialog when the cipher has at least one non-exact match uri", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com", match: UriMatchStrategy.Domain }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows the exact match dialog when the cipher uris all have a match strategy of Exact", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com/a", match: UriMatchStrategy.Exact }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); }); describe("when the default URI match strategy is not Exact", () => { @@ -273,7 +320,45 @@ describe("ItemMoreOptionsComponent", () => { mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); uriMatchStrategy$.next(UriMatchStrategy.Domain); }); - it("does not show the exact match dialog", async () => { + + it("does not show the exact match dialog when the cipher has no saved URIs", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows the exact match dialog when the cipher has only exact match saved URIs", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com/a", match: UriMatchStrategy.Exact }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("does not show the exact match dialog when the cipher has at least one uri without a match strategy of Exact", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); cipherService.getFullCipherView.mockImplementation(async (c) => ({ ...baseCipher, ...c, @@ -292,70 +377,6 @@ describe("ItemMoreOptionsComponent", () => { expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); }); - - it("shows the exact match dialog when the cipher has a single uri with a match strategy of Exact", async () => { - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [{ uri: "https://one.example.com", match: UriMatchStrategy.Exact }], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( - expect.objectContaining({ - title: expect.objectContaining({ key: "cannotAutofill" }), - content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), - type: "info", - }), - ); - expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); - expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); - }); - }); - - it("does not show the exact match dialog when the cipher has no uris", async () => { - mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - }); - - it("does not show the exact match dialog when the cipher has a uri with a match strategy of Exact and a uri with a match strategy of Domain", async () => { - mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [ - { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, - { uri: "https://page.example.com", match: UriMatchStrategy.Domain }, - ], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); }); }); @@ -384,4 +405,42 @@ describe("ItemMoreOptionsComponent", () => { }); }); }); + + describe("canAssignCollections$", () => { + it("emits true when user has organizations and editable collections", (done) => { + jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(true)); + jest + .spyOn(component["collectionService"], "decryptedCollections$") + .mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("emits false when user has no organizations", (done) => { + jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(false)); + jest + .spyOn(component["collectionService"], "decryptedCollections$") + .mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("emits false when all collections are read-only", (done) => { + jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(true)); + jest + .spyOn(component["collectionService"], "decryptedCollections$") + .mockReturnValue(of([{ id: "col-1", readOnly: true }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index c4353e17bef..f881b07282b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -204,12 +204,15 @@ export class ItemMoreOptionsComponent { } const uris = cipher.login?.uris ?? []; - const cipherHasAllExactMatchLoginUris = - uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact); - const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$); - if (cipherHasAllExactMatchLoginUris || uriMatchStrategy === UriMatchStrategy.Exact) { + const showExactMatchDialog = + uris.length === 0 + ? uriMatchStrategy === UriMatchStrategy.Exact + : // all saved URIs are exact match + uris.every((u) => (u.match ?? uriMatchStrategy) === UriMatchStrategy.Exact); + + if (showExactMatchDialog) { await this.dialogService.openSimpleDialog({ title: { key: "cannotAutofill" }, content: { key: "cannotAutofillExactMatch" }, @@ -373,7 +376,8 @@ export class ItemMoreOptionsComponent { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "archiveItem" }, - content: { key: "archiveItemConfirmDesc" }, + content: { key: "archiveItemDialogContent" }, + acceptButtonText: { key: "archiveVerb" }, type: "info", }); 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 6382b5fee0e..34454371f21 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 @@ -20,7 +20,13 @@ {{ "emptyVaultDescription" | i18n }}

- + {{ "newLogin" | i18n }} diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html index 9b8380a4214..03eb701704f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html @@ -1,39 +1,56 @@ - + + @if (cipher?.isArchived) { + + {{ "archived" | i18n }} + + } + + - + @if (cipher) { + + } - - - - - + @if (!cipher.isDeleted) { + + } + @if (cipher.isDeleted && cipher.permissions.restore) { + + } + + @if ((archiveFlagEnabled$ | async) && cipher.isArchived) { + + } + @if ((userCanArchive$ | async) && cipher.canBeArchived) { + + } + @if (canDeleteCipher$ | async) { + + } + diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts index 3d4fdb2e9f9..dd2c3e0252c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts @@ -1,9 +1,13 @@ -import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing"; +import { ComponentFixture, fakeAsync, flush, TestBed, tick } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { of, Subject } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AUTOFILL_ID, @@ -11,20 +15,32 @@ import { COPY_USERNAME_ID, COPY_VERIFICATION_CODE_ID, } from "@bitwarden/common/autofill/constants"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { EventType } from "@bitwarden/common/enums"; 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"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { TaskService } from "@bitwarden/common/vault/tasks"; import { DialogService, ToastService } from "@bitwarden/components"; -import { CopyCipherFieldService, PasswordRepromptService } from "@bitwarden/vault"; +import { + ArchiveCipherUtilitiesService, + CopyCipherFieldService, + PasswordRepromptService, +} from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; @@ -62,7 +78,10 @@ describe("ViewV2Component", () => { username: "test-username", password: "test-password", totp: "123", + uris: ["https://example.com"], }, + permissions: {}, + card: {}, } as unknown as CipherView; const mockPasswordRepromptService = { @@ -84,6 +103,8 @@ describe("ViewV2Component", () => { softDeleteWithServer: jest.fn().mockResolvedValue(undefined), }; + const cipherArchiveService = mock(); + beforeEach(async () => { mockCipherService.cipherViews$.mockClear(); mockCipherService.deleteWithServer.mockClear(); @@ -97,6 +118,10 @@ describe("ViewV2Component", () => { back.mockClear(); showToast.mockClear(); showPasswordPrompt.mockClear(); + cipherArchiveService.hasArchiveFlagEnabled$ = of(true); + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + cipherArchiveService.archiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); + cipherArchiveService.unarchiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); await TestBed.configureTestingModule({ imports: [ViewV2Component], @@ -131,7 +156,7 @@ describe("ViewV2Component", () => { { provide: CipherAuthorizationService, useValue: { - canDeleteCipher$: jest.fn().mockReturnValue(true), + canDeleteCipher$: jest.fn().mockReturnValue(of(true)), }, }, { @@ -142,6 +167,61 @@ describe("ViewV2Component", () => { provide: PasswordRepromptService, useValue: mockPasswordRepromptService, }, + { + provide: CipherArchiveService, + useValue: cipherArchiveService, + }, + { + provide: OrganizationService, + useValue: mock(), + }, + { + provide: CollectionService, + useValue: mock(), + }, + { + provide: FolderService, + useValue: mock(), + }, + { + provide: TaskService, + useValue: mock(), + }, + { + provide: ApiService, + useValue: mock(), + }, + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getIconsUrl: () => "https://example.com", + }), + }, + }, + { + provide: DomainSettingsService, + useValue: { + showFavicons$: of(true), + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)), + }, + }, + { + provide: ArchiveCipherUtilitiesService, + useValue: { + archiveCipher: jest.fn().mockResolvedValue(null), + unarchiveCipher: jest.fn().mockResolvedValue(null), + }, + }, + { + provide: CipherRiskService, + useValue: mock(), + }, ], }) .overrideProvider(DialogService, { @@ -154,6 +234,7 @@ describe("ViewV2Component", () => { fixture = TestBed.createComponent(ViewV2Component); component = fixture.componentInstance; fixture.detectChanges(); + (component as any).showFooter$ = of(true); }); describe("queryParams", () => { @@ -352,6 +433,93 @@ describe("ViewV2Component", () => { })); }); + describe("archive button", () => { + it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true)); + component.cipher = { ...mockCipher, canBeArchived: true } as CipherView; + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeTruthy(); + })); + + it("does not show the archive button when the user cannot archive", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(false)); + component.cipher = { ...mockCipher, canBeArchived: true, isDeleted: false } as CipherView; + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + + it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true)); + component.cipher = { ...mockCipher, archivedDate: new Date(), edit: true } as CipherView; + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + }); + + describe("unarchive button", () => { + it("shows the unarchive button when the cipher is archived", fakeAsync(() => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeTruthy(); + })); + + it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => { + component.cipher = { ...mockCipher, archivedDate: undefined } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeFalsy(); + })); + }); + + describe("archive", () => { + beforeEach(() => { + component.cipher = { ...mockCipher, canBeArchived: true } as CipherView; + }); + + it("calls archive service to archive the cipher", async () => { + await component.archive(); + + expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "122-333-444" }), + true, + ); + }); + }); + + describe("unarchive", () => { + it("calls archive service to unarchive the cipher", async () => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + + await component.unarchive(); + + expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "122-333-444" }), + ); + }); + }); + describe("delete", () => { beforeEach(() => { component.cipher = mockCipher; @@ -477,4 +645,44 @@ describe("ViewV2Component", () => { }); }); }); + + describe("archived badge", () => { + it("shows archived badge if the cipher is archived", fakeAsync(() => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + mockCipherService.cipherViews$.mockImplementationOnce(() => + of([ + { + ...mockCipher, + isArchived: true, + }, + ]), + ); + + params$.next({ action: "view", cipherId: mockCipher.id }); + + flush(); + + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector("span[bitBadge]"); + expect(badge).toBeTruthy(); + })); + + it("does not show archived badge if the cipher is not archived", () => { + component.cipher = { ...mockCipher, isArchived: false } as CipherView; + mockCipherService.cipherViews$.mockImplementationOnce(() => + of([ + { + ...mockCipher, + archivedDate: new Date(), + }, + ]), + ); + + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector("span[bitBadge]"); + expect(badge).toBeFalsy(); + }); + }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index 1dea91c0b9f..f57b3e2d7f1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -7,9 +7,9 @@ import { FormsModule } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom, Observable, switchMap, of, map } from "rxjs"; -import { CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -25,6 +25,7 @@ import { EventType } from "@bitwarden/common/enums"; 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 { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; @@ -34,6 +35,7 @@ import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cip import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { AsyncActionsModule, + BadgeModule, ButtonModule, CalloutModule, DialogService, @@ -42,6 +44,7 @@ import { ToastService, } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, ChangeLoginPasswordService, CipherViewComponent, CopyCipherFieldService, @@ -58,6 +61,7 @@ import { BrowserPremiumUpgradePromptService } from "../../../services/browser-pr import { BrowserViewPasswordHistoryService } from "../../../services/browser-view-password-history.service"; import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service"; import { closeViewVaultItemPopout, VaultPopoutType } from "../../../utils/vault-popout-window"; +import { ROUTES_AFTER_EDIT_DELETION } from "../add-edit/add-edit-v2.component"; import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component"; @@ -95,6 +99,7 @@ type LoadAction = AsyncActionsModule, PopOutComponent, CalloutModule, + BadgeModule, ], providers: [ { provide: ViewPasswordHistoryService, useClass: BrowserViewPasswordHistoryService }, @@ -112,8 +117,13 @@ export class ViewV2Component { collections$: Observable; loadAction: LoadAction; senderTabId?: number; + routeAfterDeletion?: ROUTES_AFTER_EDIT_DELETION; protected showFooter$: Observable; + protected userCanArchive$ = this.accountService.activeAccount$ + .pipe(getUserId) + .pipe(switchMap((userId) => this.archiveService.userCanArchive$(userId))); + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; constructor( private passwordRepromptService: PasswordRepromptService, @@ -131,6 +141,8 @@ export class ViewV2Component { protected cipherAuthorizationService: CipherAuthorizationService, private copyCipherFieldService: CopyCipherFieldService, private popupScrollPositionService: VaultPopupScrollPositionService, + private archiveService: CipherArchiveService, + private archiveCipherUtilsService: ArchiveCipherUtilitiesService, ) { this.subscribeToParams(); } @@ -141,6 +153,9 @@ export class ViewV2Component { switchMap(async (params) => { this.loadAction = params.action; this.senderTabId = params.senderTabId ? parseInt(params.senderTabId, 10) : undefined; + this.routeAfterDeletion = params.routeAfterDeletion + ? params.routeAfterDeletion + : undefined; this.activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getUserId), @@ -220,7 +235,12 @@ export class ViewV2Component { return false; } void this.router.navigate(["/edit-cipher"], { - queryParams: { cipherId: this.cipher.id, type: this.cipher.type, isNew: false }, + queryParams: { + cipherId: this.cipher.id, + type: this.cipher.type, + isNew: false, + routeAfterDeletion: this.routeAfterDeletion, + }, }); return true; } @@ -272,6 +292,24 @@ export class ViewV2Component { }); }; + archive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true); + + if (!cipherResponse) { + return; + } + this.cipher.archivedDate = new Date(cipherResponse.archivedDate); + }; + + unarchive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher); + + if (!cipherResponse) { + return; + } + this.cipher.archivedDate = null; + }; + protected deleteCipher() { return this.cipher.isDeleted ? this.cipherService.deleteWithServer(this.cipher.id, this.activeUserId) diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 513e159f7aa..7cd73279c3d 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -3,8 +3,9 @@ import { TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom, of, take, timeout } from "rxjs"; -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index 866c5dd2e89..1358c5faebe 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -3,12 +3,13 @@ import { TestBed, discardPeriodicTasks, fakeAsync, tick } from "@angular/core/te import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, skipWhile } from "rxjs"; -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import * as vaultFilterSvc from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index 439353cab50..85c415d01fe 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -14,17 +14,17 @@ import { take, } from "rxjs"; -import { - CollectionService, - CollectionTypes, - CollectionView, -} from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model"; import { sortDefaultCollections } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; diff --git a/apps/browser/src/vault/popup/settings/archive.component.html b/apps/browser/src/vault/popup/settings/archive.component.html index a7b23dc5122..01ac799ba29 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.html +++ b/apps/browser/src/vault/popup/settings/archive.component.html @@ -5,6 +5,21 @@ + @if (showSubscriptionEndedMessaging$ | async) { + +
+ +

{{ "premiumSubscriptionEnded" | i18n }}

+
+

+ {{ "archivePremiumRestart" | i18n }} +

+ +
+ } + @if (archivedCiphers$ | async; as archivedItems) { @if (archivedItems.length) { @@ -27,9 +42,23 @@ {{ cipher.name }} - @if (CipherViewLikeUtils.hasAttachments(cipher)) { - - } +
+ @if (cipher.organizationId) { + + } + @if (CipherViewLikeUtils.hasAttachments(cipher)) { + + } +
{{ CipherViewLikeUtils.subtitle(cipher) }} @@ -48,6 +77,15 @@ + @if (canAssignCollections$ | async) { + + } diff --git a/apps/browser/src/vault/popup/settings/archive.component.spec.ts b/apps/browser/src/vault/popup/settings/archive.component.spec.ts new file mode 100644 index 00000000000..2f5cfb8d824 --- /dev/null +++ b/apps/browser/src/vault/popup/settings/archive.component.spec.ts @@ -0,0 +1,140 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { PopupRouterCacheService } from "@bitwarden/browser/platform/popup/view-cache/popup-router-cache.service"; +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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { ArchiveComponent } from "./archive.component"; + +// 'qrcode-parser' is used by `BrowserTotpCaptureService` but is an es6 module that jest can't compile. +// Mock the entire module here to prevent jest from throwing an error. I wasn't able to find a way to mock the +// `BrowserTotpCaptureService` where jest would not load the file in the first place. +jest.mock("qrcode-parser", () => {}); + +describe("ArchiveComponent", () => { + let component: ArchiveComponent; + + let hasOrganizations: jest.Mock; + let decryptedCollections$: jest.Mock; + let navigate: jest.Mock; + let showPasswordPrompt: jest.Mock; + + beforeAll(async () => { + navigate = jest.fn(); + showPasswordPrompt = jest.fn().mockResolvedValue(true); + hasOrganizations = jest.fn(); + decryptedCollections$ = jest.fn(); + + await TestBed.configureTestingModule({ + providers: [ + { provide: Router, useValue: { navigate } }, + { + provide: AccountService, + useValue: { activeAccount$: new BehaviorSubject({ id: "user-id" }) }, + }, + { provide: PasswordRepromptService, useValue: { showPasswordPrompt } }, + { provide: OrganizationService, useValue: { hasOrganizations } }, + { provide: CollectionService, useValue: { decryptedCollections$ } }, + { provide: DialogService, useValue: mock() }, + { provide: CipherService, useValue: mock() }, + { provide: CipherArchiveService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + { provide: PopupRouterCacheService, useValue: mock() }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: LogService, useValue: mock() }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(ArchiveComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("canAssignCollections$", () => { + it("emits true when user has organizations and editable collections", (done) => { + hasOrganizations.mockReturnValue(of(true)); + decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("emits false when user has no organizations", (done) => { + hasOrganizations.mockReturnValue(of(false)); + decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("emits false when all collections are read-only", (done) => { + hasOrganizations.mockReturnValue(of(true)); + decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: true }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + }); + + describe("conditionallyNavigateToAssignCollections", () => { + const mockCipher = { + id: "cipher-1", + reprompt: 0, + } as CipherViewLike; + + it("navigates to assign-collections when reprompt is not required", async () => { + await component.conditionallyNavigateToAssignCollections(mockCipher); + + expect(navigate).toHaveBeenCalledWith(["/assign-collections"], { + queryParams: { cipherId: "cipher-1" }, + }); + }); + + it("prompts for password when reprompt is required", async () => { + const cipherWithReprompt = { ...mockCipher, reprompt: 1 }; + + await component.conditionallyNavigateToAssignCollections( + cipherWithReprompt as CipherViewLike, + ); + + expect(showPasswordPrompt).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(["/assign-collections"], { + queryParams: { cipherId: "cipher-1" }, + }); + }); + + it("does not navigate when password prompt is cancelled", async () => { + const cipherWithReprompt = { ...mockCipher, reprompt: 1 }; + showPasswordPrompt.mockResolvedValueOnce(false); + + await component.conditionallyNavigateToAssignCollections( + cipherWithReprompt as CipherViewLike, + ); + + expect(showPasswordPrompt).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/settings/archive.component.ts b/apps/browser/src/vault/popup/settings/archive.component.ts index b1c78444a3f..8a81d733039 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.ts +++ b/apps/browser/src/vault/popup/settings/archive.component.ts @@ -1,9 +1,13 @@ import { CommonModule } from "@angular/common"; import { Component, inject } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { firstValueFrom, map, Observable, startWith, switchMap } from "rxjs"; +import { combineLatest, firstValueFrom, map, Observable, startWith, switchMap } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -25,16 +29,20 @@ import { SectionHeaderComponent, ToastService, TypographyModule, + CardComponent, + ButtonComponent, } from "@bitwarden/components"; import { CanDeleteCipherDirective, DecryptionFailureDialogComponent, + OrgIconDirective, PasswordRepromptService, } from "@bitwarden/vault"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +import { ROUTES_AFTER_EDIT_DELETION } from "../components/vault-v2/add-edit/add-edit-v2.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -55,6 +63,9 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co SectionComponent, SectionHeaderComponent, TypographyModule, + OrgIconDirective, + CardComponent, + ButtonComponent, ], }) export class ArchiveComponent { @@ -67,13 +78,38 @@ export class ArchiveComponent { private i18nService = inject(I18nService); private cipherArchiveService = inject(CipherArchiveService); private passwordRepromptService = inject(PasswordRepromptService); + private organizationService = inject(OrganizationService); + private collectionService = inject(CollectionService); private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); + private readonly orgMap = toSignal( + this.userId$.pipe( + switchMap((userId) => + this.organizationService.organizations$(userId).pipe( + map((orgs) => { + const map = new Map(); + for (const org of orgs) { + map.set(org.id, org); + } + return map; + }), + ), + ), + ), + ); + + private readonly collections = toSignal( + this.userId$.pipe(switchMap((userId) => this.collectionService.decryptedCollections$(userId))), + ); + protected archivedCiphers$ = this.userId$.pipe( switchMap((userId) => this.cipherArchiveService.archivedCiphers$(userId)), ); + protected userCanArchive$ = this.userId$.pipe( + switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)), + ); protected CipherViewLikeUtils = CipherViewLikeUtils; protected loading$ = this.archivedCiphers$.pipe( @@ -81,13 +117,39 @@ export class ArchiveComponent { startWith(true), ); + protected canAssignCollections$ = this.userId$.pipe( + switchMap((userId) => { + return combineLatest([ + this.organizationService.hasOrganizations(userId), + this.collectionService.decryptedCollections$(userId), + ]).pipe( + map(([hasOrgs, collections]) => { + const canEditCollections = collections.some((c) => !c.readOnly); + return hasOrgs && canEditCollections; + }), + ); + }), + ); + + protected showSubscriptionEndedMessaging$ = this.userId$.pipe( + switchMap((userId) => this.cipherArchiveService.showSubscriptionEndedMessaging$(userId)), + ); + + async navigateToPremium() { + await this.router.navigate(["/premium"]); + } + async view(cipher: CipherViewLike) { if (!(await this.canInteract(cipher))) { return; } await this.router.navigate(["/view-cipher"], { - queryParams: { cipherId: cipher.id, type: cipher.type }, + queryParams: { + cipherId: cipher.id, + type: cipher.type, + routeAfterDeletion: ROUTES_AFTER_EDIT_DELETION.archive, + }, }); } @@ -97,7 +159,11 @@ export class ArchiveComponent { } await this.router.navigate(["/edit-cipher"], { - queryParams: { cipherId: cipher.id, type: cipher.type }, + queryParams: { + cipherId: cipher.id, + type: cipher.type, + routeAfterDeletion: ROUTES_AFTER_EDIT_DELETION.archive, + }, }); } @@ -173,6 +239,17 @@ export class ArchiveComponent { }); } + /** Prompts for password when necessary then navigates to the assign collections route */ + async conditionallyNavigateToAssignCollections(cipher: CipherViewLike) { + if (cipher.reprompt && !(await this.passwordRepromptService.showPasswordPrompt())) { + return; + } + + await this.router.navigate(["/assign-collections"], { + queryParams: { cipherId: cipher.id }, + }); + } + /** * Check if the user is able to interact with the cipher * (password re-prompt / decryption failure checks). @@ -189,4 +266,22 @@ export class ArchiveComponent { return this.passwordRepromptService.passwordRepromptCheck(cipher); } + + /** + * Get the organization tier type for the given cipher. + */ + orgTierType({ organizationId }: CipherViewLike) { + return this.orgMap()?.get(organizationId as string)?.productTierType; + } + + /** + * Get the organization icon tooltip for the given cipher. + */ + orgIconTooltip({ collectionIds }: CipherViewLike) { + if (collectionIds.length !== 1) { + return this.i18nService.t("nCollections", collectionIds.length); + } + + return this.collections()?.find((c) => c.id === collectionIds[0])?.name; + } } diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html index d5b94df5008..ad009c7a60b 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html @@ -52,9 +52,7 @@ > {{ "archiveNoun" | i18n }} - @if (!userHasArchivedItems()) { - - } + diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts index 15ddb7507fd..554570de7f9 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts @@ -195,10 +195,10 @@ describe("VaultSettingsV2Component", () => { expect(component["userHasArchivedItems"]()).toBe(false); }); - it("hides premium badge when user has archived items", () => { + it("shows premium badge when user has archived items but cannot archive", () => { setArchiveState(false, [{ id: "cipher1" } as CipherView]); - expect(component["premiumBadgeComponent"]()).toBeUndefined(); + expect(component["premiumBadgeComponent"]()).toBeTruthy(); expect(component["userHasArchivedItems"]()).toBe(true); }); }); diff --git a/apps/browser/src/vault/popup/views/popup-cipher.view.ts b/apps/browser/src/vault/popup/views/popup-cipher.view.ts index 6f85e7b6eb4..7d035ceb6df 100644 --- a/apps/browser/src/vault/popup/views/popup-cipher.view.ts +++ b/apps/browser/src/vault/popup/views/popup-cipher.view.ts @@ -1,4 +1,4 @@ -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherListView } from "@bitwarden/sdk-internal"; diff --git a/apps/cli/src/admin-console/models/response/collection.response.ts b/apps/cli/src/admin-console/models/response/collection.response.ts index a0d1ce1047d..4c56fdcd84a 100644 --- a/apps/cli/src/admin-console/models/response/collection.response.ts +++ b/apps/cli/src/admin-console/models/response/collection.response.ts @@ -1,4 +1,4 @@ -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { CollectionWithIdExport } from "@bitwarden/common/models/export/collection-with-id.export"; import { BaseResponse } from "../../../models/response/base.response"; diff --git a/apps/cli/src/admin-console/models/response/organization-collection.response.ts b/apps/cli/src/admin-console/models/response/organization-collection.response.ts index a0d62b4c7b6..4b5c9a08f2b 100644 --- a/apps/cli/src/admin-console/models/response/organization-collection.response.ts +++ b/apps/cli/src/admin-console/models/response/organization-collection.response.ts @@ -1,4 +1,4 @@ -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { SelectionReadOnly } from "../selection-read-only"; diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index 35816b56fb2..db070344628 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -1,12 +1,11 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { filter, firstValueFrom, map, switchMap } from "rxjs"; -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index ff210cf222d..2430035e34a 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -1,16 +1,15 @@ import { firstValueFrom, map } from "rxjs"; +import { OrganizationUserApiService, CollectionService } from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { - OrganizationUserApiService, - CollectionService, CollectionData, Collection, CollectionDetailsResponse as ApiCollectionDetailsResponse, CollectionResponse as ApiCollectionResponse, -} from "@bitwarden/admin-console/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +} from "@bitwarden/common/admin-console/models/collections"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EventType } from "@bitwarden/common/enums"; diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index 7803f6f94d4..91e579c26c1 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -9,9 +9,9 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NodeUtils } from "@bitwarden/node/node-utils"; import { Response } from "../../../models/response"; diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index bf53c8a5cb9..2c6d41d66ac 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -5,9 +5,9 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; import { CliUtils } from "../../../utils"; diff --git a/apps/cli/src/tools/send/commands/receive.command.ts b/apps/cli/src/tools/send/commands/receive.command.ts index a412f7c1667..5cbf458c87f 100644 --- a/apps/cli/src/tools/send/commands/receive.command.ts +++ b/apps/cli/src/tools/send/commands/receive.command.ts @@ -13,11 +13,11 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { KeyService } from "@bitwarden/key-management"; import { NodeUtils } from "@bitwarden/node/node-utils"; diff --git a/apps/cli/src/tools/send/commands/template.command.ts b/apps/cli/src/tools/send/commands/template.command.ts index c1c2c97b03d..09213ac5fa8 100644 --- a/apps/cli/src/tools/send/commands/template.command.ts +++ b/apps/cli/src/tools/send/commands/template.command.ts @@ -1,4 +1,4 @@ -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; import { TemplateResponse } from "../../../models/response/template.response"; diff --git a/apps/cli/src/tools/send/models/send-access.response.ts b/apps/cli/src/tools/send/models/send-access.response.ts index 07877bfb548..7bd54801307 100644 --- a/apps/cli/src/tools/send/models/send-access.response.ts +++ b/apps/cli/src/tools/send/models/send-access.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../models/response/base.response"; diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index a0c1d3f83c6..b7655226be0 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -1,8 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../models/response/base.response"; diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 33bf4518ccd..869d77a379c 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -7,7 +7,7 @@ import * as chalk from "chalk"; import { program, Command, Option, OptionValues } from "commander"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseProgram } from "../../base-program"; import { Response } from "../../models/response"; diff --git a/apps/cli/src/utils.ts b/apps/cli/src/utils.ts index e321adbfd5e..72746cb9b71 100644 --- a/apps/cli/src/utils.ts +++ b/apps/cli/src/utils.ts @@ -1,12 +1,10 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import * as fs from "fs"; import * as path from "path"; import * as inquirer from "inquirer"; import * as JSZip from "jszip"; -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; diff --git a/apps/desktop/custom-appx-manifest.xml b/apps/desktop/custom-appx-manifest.xml new file mode 100644 index 00000000000..2f7796c97cf --- /dev/null +++ b/apps/desktop/custom-appx-manifest.xml @@ -0,0 +1,111 @@ + + + + + + + + ${displayName} + ${publisherDisplayName} + A secure and free password manager for all of your devices. + assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index d4a5ccf7aca..3e5225d4b5a 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -386,9 +386,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bitwarden-russh" @@ -1731,19 +1731,18 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.25" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "macos_provider" @@ -1994,11 +1993,10 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ - "byteorder", "lazy_static", "libm", "num-integer", @@ -2258,9 +2256,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -2268,15 +2266,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -2653,9 +2651,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.6" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", "digest", @@ -2915,11 +2913,12 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.2.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" dependencies = [ - "futures", + "futures-executor", + "futures-util", "log", "once_cell", "parking_lot", @@ -2929,9 +2928,9 @@ dependencies = [ [[package]] name = "serial_test_derive" -version = "3.2.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" dependencies = [ "proc-macro2", "quote", @@ -2951,9 +2950,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -3011,9 +3010,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smawk" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 86eb507a6c1..facd9554af1 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -9,7 +9,7 @@ members = [ "napi", "process_isolation", "proxy", - "windows_plugin_authenticator" + "windows_plugin_authenticator", ] [workspace.package] @@ -50,7 +50,7 @@ oo7 = "=0.5.0" pin-project = "=1.1.10" pkcs8 = "=0.10.2" rand = "=0.9.2" -rsa = "=0.9.6" +rsa = "=0.9.10" russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" secmem-proc = "=0.3.7" @@ -58,7 +58,7 @@ security-framework = "=3.5.1" security-framework-sys = "=2.15.0" serde = "=1.0.209" serde_json = "=1.0.127" -sha2 = "=0.10.8" +sha2 = "=0.10.9" ssh-encoding = "=0.2.0" ssh-key = { version = "=0.6.7", default-features = false } sysinfo = "=0.37.2" diff --git a/apps/desktop/desktop_native/autotype/Cargo.toml b/apps/desktop/desktop_native/autotype/Cargo.toml index 6bf3218d98a..a9c826af57d 100644 --- a/apps/desktop/desktop_native/autotype/Cargo.toml +++ b/apps/desktop/desktop_native/autotype/Cargo.toml @@ -5,13 +5,10 @@ license.workspace = true edition.workspace = true publish.workspace = true -[dependencies] -anyhow = { workspace = true } - [target.'cfg(windows)'.dependencies] itertools.workspace = true mockall = "=0.14.0" -serial_test = "=3.2.0" +serial_test = "=3.3.1" tracing.workspace = true windows = { workspace = true, features = [ "Win32_UI_Input_KeyboardAndMouse", @@ -19,5 +16,8 @@ windows = { workspace = true, features = [ ] } windows-core = { workspace = true } +[dependencies] +anyhow = { workspace = true } + [lints] workspace = true diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml index ff641731661..5cc457809f2 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml @@ -9,19 +9,19 @@ publish.workspace = true [target.'cfg(target_os = "windows")'.dependencies] aes-gcm = { workspace = true } +anyhow = { workspace = true } +base64 = { workspace = true } chacha20poly1305 = { workspace = true } chromium_importer = { path = "../chromium_importer" } clap = { version = "=4.5.53", features = ["derive"] } scopeguard = { workspace = true } sysinfo = { workspace = true } -windows = { workspace = true, features = [ - "Win32_System_Pipes", -] } -anyhow = { workspace = true } -base64 = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } +windows = { workspace = true, features = [ + "Win32_System_Pipes", +] } [build-dependencies] embed-resource = "=3.0.6" diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index 54a6dba8326..b20aa7e5af8 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -20,47 +20,79 @@ fs.mkdirSync(path.join(__dirname, "dist"), { recursive: true }); const args = process.argv.slice(2); // Get arguments passed to the script const mode = args.includes("--release") ? "release" : "debug"; +const isRelease = mode === "release"; const targetArg = args.find(arg => arg.startsWith("--target=")); const target = targetArg ? targetArg.split("=")[1] : null; let crossPlatform = process.argv.length > 2 && process.argv[2] === "cross-platform"; +/** + * Execute a command. + * @param {string} bin Executable to run. + * @param {string[]} args Arguments for executable. + * @param {string} [workingDirectory] Path to working directory, relative to the script directory. Defaults to the script directory. + * @param {string} [useShell] Whether to use a shell to execute the command. Defaults to false. + */ +function runCommand(bin, args, workingDirectory = "", useShell = false) { + const options = { stdio: 'inherit', cwd: path.resolve(__dirname, workingDirectory), shell: useShell } + console.debug("Running command:", bin, args, options) + child_process.execFileSync(bin, args, options) +} + function buildNapiModule(target, release = true) { - const targetArg = target ? `--target ${target}` : ""; + const targetArg = target ? `--target=${target}` : ""; const releaseArg = release ? "--release" : ""; - child_process.execSync(`npm run build -- ${releaseArg} ${targetArg}`, { stdio: 'inherit', cwd: path.join(__dirname, "napi") }); + const crossCompileArg = effectivePlatform(target) !== process.platform ? "--cross-compile" : ""; + runCommand("npm", ["run", "build", "--", crossCompileArg, releaseArg, targetArg].filter(s => s != ''), "./napi", true); +} + +/** + * Build a Rust binary with Cargo. + * + * If {@link target} is specified, cross-compilation helpers are used to build if necessary, and the resulting + * binary is copied to the `dist` folder. + * @param {string} bin Name of cargo binary package in `desktop_native` workspace. + * @param {string?} target Rust compiler target, e.g. `aarch64-pc-windows-msvc`. + * @param {boolean} release Whether to build in release mode. + */ +function cargoBuild(bin, target, release) { + const targetArg = target ? `--target=${target}` : ""; + const releaseArg = release ? "--release" : ""; + const args = ["build", "--bin", bin, releaseArg, targetArg] + // Use cross-compilation helper if necessary + if (effectivePlatform(target) === "win32" && process.platform !== "win32") { + args.unshift("xwin") + } + runCommand("cargo", args.filter(s => s != '')) + + // Infer the architecture and platform if not passed explicitly + let nodeArch, platform; + if (target) { + nodeArch = rustTargetsMap[target].nodeArch; + platform = rustTargetsMap[target].platform; + } + else { + nodeArch = process.arch; + platform = process.platform; + } + + // Copy the resulting binary to the dist folder + const profileFolder = isRelease ? "release" : "debug"; + const ext = platform === "win32" ? ".exe" : ""; + const src = path.join(__dirname, "target", target ? target : "", profileFolder, `${bin}${ext}`) + const dst = path.join(__dirname, "dist", `${bin}.${platform}-${nodeArch}${ext}`) + console.log(`Copying ${src} to ${dst}`); + fs.copyFileSync(src, dst); } function buildProxyBin(target, release = true) { - const targetArg = target ? `--target ${target}` : ""; - const releaseArg = release ? "--release" : ""; - child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")}); - - if (target) { - // Copy the resulting binary to the dist folder - const targetFolder = release ? "release" : "debug"; - const ext = process.platform === "win32" ? ".exe" : ""; - const nodeArch = rustTargetsMap[target].nodeArch; - fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `desktop_proxy${ext}`), path.join(__dirname, "dist", `desktop_proxy.${process.platform}-${nodeArch}${ext}`)); - } + cargoBuild("desktop_proxy", target, release) } function buildImporterBinaries(target, release = true) { // These binaries are only built for Windows, so we can skip them on other platforms - if (process.platform !== "win32") { - return; - } - - const bin = "bitwarden_chromium_import_helper"; - const targetArg = target ? `--target ${target}` : ""; - const releaseArg = release ? "--release" : ""; - child_process.execSync(`cargo build --bin ${bin} ${releaseArg} ${targetArg}`); - - if (target) { - // Copy the resulting binary to the dist folder - const targetFolder = release ? "release" : "debug"; - const nodeArch = rustTargetsMap[target].nodeArch; - fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `${bin}.exe`), path.join(__dirname, "dist", `${bin}.${process.platform}-${nodeArch}.exe`)); + if (effectivePlatform(target) == "win32") { + cargoBuild("bitwarden_chromium_import_helper", target, release) } } @@ -69,17 +101,29 @@ function buildProcessIsolation() { return; } - child_process.execSync(`cargo build --release`, { - stdio: 'inherit', - cwd: path.join(__dirname, "process_isolation") - }); + runCommand("cargo", ["build", "--package", "process_isolation", "--release"]); console.log("Copying process isolation library to dist folder"); fs.copyFileSync(path.join(__dirname, "target", "release", "libprocess_isolation.so"), path.join(__dirname, "dist", `libprocess_isolation.so`)); } function installTarget(target) { - child_process.execSync(`rustup target add ${target}`, { stdio: 'inherit', cwd: __dirname }); + runCommand("rustup", ["target", "add", target]); + // Install cargo-xwin for cross-platform builds targeting Windows + if (target.includes('windows') && process.platform !== 'win32') { + runCommand("cargo", ["install", "--version", "0.20.2", "--locked", "cargo-xwin"]); + // install tools needed for packaging Appx, only supported on macOS for now. + if (process.platform === "darwin") { + runCommand("brew", ["install", "iinuwa/msix-packaging-tap/msix-packaging", "osslsigncode"]); + } + } +} + +function effectivePlatform(target) { + if (target) { + return rustTargetsMap[target].platform + } + return process.platform } if (!crossPlatform && !target) { @@ -94,9 +138,9 @@ if (!crossPlatform && !target) { if (target) { console.log(`Building for target: ${target} in ${mode} mode`); installTarget(target); - buildNapiModule(target, mode === "release"); - buildProxyBin(target, mode === "release"); - buildImporterBinaries(false, mode === "release"); + buildNapiModule(target, isRelease); + buildProxyBin(target, isRelease); + buildImporterBinaries(target, isRelease); buildProcessIsolation(); return; } @@ -113,8 +157,8 @@ if (process.platform === "linux") { platformTargets.forEach(([target, _]) => { installTarget(target); - buildNapiModule(target, mode === "release"); - buildProxyBin(target, mode === "release"); - buildImporterBinaries(target, mode === "release"); + buildNapiModule(target, isRelease); + buildProxyBin(target, isRelease); + buildImporterBinaries(target, isRelease); buildProcessIsolation(); }); diff --git a/apps/desktop/desktop_native/chromium_importer/Cargo.toml b/apps/desktop/desktop_native/chromium_importer/Cargo.toml index 9e9a9e0fee8..9bb1c0b87f2 100644 --- a/apps/desktop/desktop_native/chromium_importer/Cargo.toml +++ b/apps/desktop/desktop_native/chromium_importer/Cargo.toml @@ -16,6 +16,12 @@ rusqlite = { version = "=0.37.0", features = ["bundled"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +cbc = { workspace = true, features = ["alloc"] } +oo7 = { workspace = true } +pbkdf2 = "=0.12.2" +sha1 = "=0.10.6" + [target.'cfg(target_os = "macos")'.dependencies] cbc = { workspace = true, features = ["alloc"] } pbkdf2 = "=0.12.2" @@ -25,20 +31,14 @@ sha1 = "=0.10.6" [target.'cfg(target_os = "windows")'.dependencies] aes-gcm = { workspace = true } base64 = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +verifysign = "=0.2.4" windows = { workspace = true, features = [ "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } -verifysign = "=0.2.4" -tokio = { workspace = true, features = ["full"] } -tracing = { workspace = true } - -[target.'cfg(target_os = "linux")'.dependencies] -cbc = { workspace = true, features = ["alloc"] } -oo7 = { workspace = true } -pbkdf2 = "=0.12.2" -sha1 = "=0.10.6" [lints] workspace = true diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index dc9246f55c6..aa5d564c9e5 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -13,7 +13,7 @@ default = [ "dep:security-framework", "dep:security-framework-sys", "dep:zbus", - "dep:zbus_polkit" + "dep:zbus_polkit", ] manual_test = [] @@ -46,6 +46,23 @@ tracing = { workspace = true } typenum = { workspace = true } zeroizing-alloc = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +ashpd = { workspace = true } +homedir = { workspace = true } +libc = { workspace = true } +linux-keyutils = { workspace = true } +oo7 = { workspace = true } +zbus = { workspace = true, optional = true } +zbus_polkit = { workspace = true, optional = true } + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = { workspace = true, optional = true } +desktop_objc = { path = "../objc" } +homedir = { workspace = true } +secmem-proc = { workspace = true } +security-framework = { workspace = true, optional = true } +security-framework-sys = { workspace = true, optional = true } + [target.'cfg(windows)'.dependencies] pin-project = { workspace = true } scopeguard = { workspace = true } @@ -68,22 +85,5 @@ windows = { workspace = true, features = [ ], optional = true } windows-future = { workspace = true } -[target.'cfg(target_os = "macos")'.dependencies] -core-foundation = { workspace = true, optional = true } -homedir = { workspace = true } -secmem-proc = { workspace = true } -security-framework = { workspace = true, optional = true } -security-framework-sys = { workspace = true, optional = true } -desktop_objc = { path = "../objc" } - -[target.'cfg(target_os = "linux")'.dependencies] -ashpd = { workspace = true } -homedir = { workspace = true } -libc = { workspace = true } -linux-keyutils = { workspace = true } -oo7 = { workspace = true } -zbus = { workspace = true, optional = true } -zbus_polkit = { workspace = true, optional = true } - [lints] workspace = true diff --git a/apps/desktop/desktop_native/macos_provider/Cargo.toml b/apps/desktop/desktop_native/macos_provider/Cargo.toml index 8a34460268a..d73bd2fa049 100644 --- a/apps/desktop/desktop_native/macos_provider/Cargo.toml +++ b/apps/desktop/desktop_native/macos_provider/Cargo.toml @@ -5,14 +5,14 @@ license = { workspace = true } version = { workspace = true } publish = { workspace = true } -[[bin]] -name = "uniffi-bindgen" -path = "uniffi-bindgen.rs" - [lib] crate-type = ["staticlib", "cdylib"] bench = false +[[bin]] +name = "uniffi-bindgen" +path = "uniffi-bindgen.rs" + [dependencies] uniffi = { workspace = true, features = ["cli"] } @@ -23,8 +23,8 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true } -tracing-subscriber = { workspace = true } tracing-oslog = "=0.3.0" +tracing-subscriber = { workspace = true } [build-dependencies] uniffi = { workspace = true, features = ["build"] } diff --git a/apps/desktop/desktop_native/windows_plugin_authenticator/Cargo.toml b/apps/desktop/desktop_native/windows_plugin_authenticator/Cargo.toml index 17c834325a4..9fd873d868e 100644 --- a/apps/desktop/desktop_native/windows_plugin_authenticator/Cargo.toml +++ b/apps/desktop/desktop_native/windows_plugin_authenticator/Cargo.toml @@ -6,6 +6,7 @@ license = { workspace = true } publish = { workspace = true } [target.'cfg(windows)'.dependencies] +hex = { workspace = true } windows = { workspace = true, features = [ "Win32_Foundation", "Win32_Security", @@ -13,7 +14,6 @@ windows = { workspace = true, features = [ "Win32_System_LibraryLoader", ] } windows-core = { workspace = true } -hex = { workspace = true } [lints] workspace = true diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json index 0c95c7f01a6..3e1ca673c3c 100644 --- a/apps/desktop/electron-builder.beta.json +++ b/apps/desktop/electron-builder.beta.json @@ -1,4 +1,6 @@ { + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "extraMetadata": { "name": "bitwarden-beta" }, @@ -13,14 +15,15 @@ }, "afterSign": "scripts/after-sign.js", "afterPack": "scripts/after-pack.js", - "asarUnpack": ["**/*.node"], + "beforePack": "scripts/before-pack.js", "files": [ - "**/*", - "!**/node_modules/@bitwarden/desktop-napi/**/*", - "**/node_modules/@bitwarden/desktop-napi/index.js", - "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" + "!node_modules/@bitwarden/desktop-napi/scripts", + "!node_modules/@bitwarden/desktop-napi/src", + "!node_modules/@bitwarden/desktop-napi/Cargo.toml", + "!node_modules/@bitwarden/desktop-napi/build.rs", + "!node_modules/@bitwarden/desktop-napi/package.json" ], - "electronVersion": "36.8.1", + "electronVersion": "37.7.0", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", @@ -34,11 +37,11 @@ }, "extraFiles": [ { - "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", + "from": "desktop_native/dist/desktop_proxy.win32-${arch}.exe", "to": "desktop_proxy.exe" }, { - "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "from": "desktop_native/dist/bitwarden_chromium_import_helper.win32-${arch}.exe", "to": "bitwarden_chromium_import_helper.exe" } ] @@ -58,9 +61,10 @@ "appx": { "artifactName": "Bitwarden-Beta-${version}-${arch}.${ext}", "backgroundColor": "#175DDC", + "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "BitwardenBeta", "identityName": "8bitSolutionsLLC.BitwardenBeta", - "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", + "publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US", "publisherDisplayName": "Bitwarden Inc", "languages": [ "en-US", diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index a4e1c44dc5b..83bd2921551 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -1,4 +1,6 @@ { + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "extraMetadata": { "name": "bitwarden" }, @@ -13,12 +15,13 @@ }, "afterSign": "scripts/after-sign.js", "afterPack": "scripts/after-pack.js", - "asarUnpack": ["**/*.node"], + "beforePack": "scripts/before-pack.js", "files": [ - "**/*", - "!**/node_modules/@bitwarden/desktop-napi/**/*", - "**/node_modules/@bitwarden/desktop-napi/index.js", - "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" + "!node_modules/@bitwarden/desktop-napi/scripts", + "!node_modules/@bitwarden/desktop-napi/src", + "!node_modules/@bitwarden/desktop-napi/Cargo.toml", + "!node_modules/@bitwarden/desktop-napi/build.rs", + "!node_modules/@bitwarden/desktop-napi/package.json" ], "electronVersion": "39.2.6", "generateUpdatesFilesForAllChannels": true, @@ -94,11 +97,11 @@ }, "extraFiles": [ { - "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", + "from": "desktop_native/dist/desktop_proxy.win32-${arch}.exe", "to": "desktop_proxy.exe" }, { - "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "from": "desktop_native/dist/bitwarden_chromium_import_helper.win32-${arch}.exe", "to": "bitwarden_chromium_import_helper.exe" } ] @@ -172,9 +175,10 @@ "appx": { "artifactName": "${productName}-${version}-${arch}.${ext}", "backgroundColor": "#175DDC", + "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "bitwardendesktop", "identityName": "8bitSolutionsLLC.bitwardendesktop", - "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", + "publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US", "publisherDisplayName": "Bitwarden Inc", "languages": [ "en-US", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 17322c42a84..ad20e7c0e69 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -29,7 +29,7 @@ "build:macos-extension:mas": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas", "build:macos-extension:masdev": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas-dev", "build:main": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main", - "build:main:dev": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main", + "build:main:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main", "build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main --watch", "build:renderer": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer", "build:renderer:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer", @@ -48,8 +48,8 @@ "pack:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never", "pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never", "pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"", - "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", - "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", + "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", + "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never", "pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", "dist:dir": "npm run build && npm run pack:dir", "dist:lin": "npm run build && npm run pack:lin", @@ -62,7 +62,7 @@ "publish:lin": "npm run build && npm run clean:dist && electron-builder --linux --x64 -p always", "publish:mac": "npm run build && npm run clean:dist && electron-builder --mac -p always", "publish:mac:mas": "npm run dist:mac:mas && npm run upload:mas", - "publish:win": "npm run build && npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p always -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", + "publish:win": "npm run build && npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p always", "publish:win:dev": "npm run build:dev && npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p always", "upload:mas": "xcrun altool --upload-app --type osx --file \"$(find ./dist/mas-universal/Bitwarden*.pkg)\" --apiKey $APP_STORE_CONNECT_AUTH_KEY --apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER", "test": "jest", diff --git a/apps/desktop/scripts/after-pack.js b/apps/desktop/scripts/after-pack.js index 5fc42f31ac3..34378ee092b 100644 --- a/apps/desktop/scripts/after-pack.js +++ b/apps/desktop/scripts/after-pack.js @@ -6,9 +6,12 @@ const path = require("path"); const { flipFuses, FuseVersion, FuseV1Options } = require("@electron/fuses"); const builder = require("electron-builder"); const fse = require("fs-extra"); - exports.default = run; +/** + * + * @param {builder.AfterPackContext} context + */ async function run(context) { console.log("## After pack"); // console.log(context); diff --git a/apps/desktop/scripts/appx-cross-build.ps1 b/apps/desktop/scripts/appx-cross-build.ps1 new file mode 100755 index 00000000000..62619d5ea37 --- /dev/null +++ b/apps/desktop/scripts/appx-cross-build.ps1 @@ -0,0 +1,226 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS +Script to build, package and sign the Bitwarden desktop client as a Windows Appx +package. + +.DESCRIPTION +This script provides cross-platform support for packaging and signing the +Bitwarden desktop client as a Windows Appx package. + +Currently, only macOS -> Windows Appx is supported, but Linux -> Windows Appx +could be added in the future by providing Linux binaries for the msix-packaging +project. + +.NOTES +The reason this script exists is because electron-builder does not currently +support cross-platform Appx packaging without proprietary tools (Parallels +Windows VM). This script uses third-party tools (makemsix from msix-packaging +and osslsigncode) to package and sign the Appx. + +The signing certificate must have the same subject as the publisher name. This +can be generated on the Windows target using PowerShell 5.1 and copied to the +host, or directly on the host with OpenSSL. + +Using Windows PowerShell 5.1: +```powershell +$publisher = "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US" +$certificate = New-SelfSignedCertificate -Type Custom -KeyUsage DigitalSignature -CertStoreLocation "Cert:\CurrentUser\My" -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") -Subject $publisher -FriendlyName "Bitwarden Developer Signing Certificate" +$password = Read-Host -AsSecureString +Export-PfxCertificate -cert "Cert:\CurrentUser\My\${$certificate.Thumbprint}" -FilePath "C:\path/to/pfx" -Password $password +``` + +Using OpenSSL: +```sh +subject="jurisdictionCountryName=US/jurisdictionStateOrProvinceName=Delaware/businessCategory=Private Organization/serialNumber=7654941, C=US, ST=California, L=Santa Barbara, O=Bitwarden Inc., CN=Bitwarden Inc." +keyfile="/tmp/mysigning.rsa.pem" +certfile="/tmp/mysigning.cert.pem" +p12file="/tmp/mysigning.p12" +openssl req -x509 -keyout "$keyfile" -out "$certfile" -subj "$subject" \ + -newkey rsa:2048 -days 3650 -nodes \ + -addext 'keyUsage=critical,digitalSignature' \ + -addext 'extendedKeyUsage=critical,codeSigning' \ + -addext 'basicConstraints=critical,CA:FALSE' +openssl pkcs12 -inkey "$keyfile" -in "$certfile" -export -out "$p12file" +rm $keyfile +``` + +.EXAMPLE +./scripts/cross-build.ps1 -Architecture arm64 -CertificatePath ~/Development/code-signing.pfx -CertificatePassword (Read-Host -AsSecureString) -Release -Beta + +Reads the signing certificate password from user input, then builds, packages +and signs the Appx. + +Alternatively, you can specify the CERTIFICATE_PASSWORD environment variable. +#> +param( + [Parameter(Mandatory=$true)] + [ValidateSet("X64", "ARM64")]$Architecture, + [string] + # Path to PKCS12 certificate file. If not specified, the Appx will not be signed. + $CertificatePath, + [SecureString] + # Password for PKCS12 certificate. Alternatively, may be specified in + # CERTIFICATE_PASSWORD environment variable. If not specified, the Appx will + # not be signed. + $CertificatePassword, + [Switch] + # Whether to build the Beta version of the app. + $Beta=$false, + [Switch] + # Whether to build in release mode. + $Release=$false +) +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true +$startTime = Get-Date +$originalLocation = Get-Location +if (!(Get-Command makemsix -ErrorAction SilentlyContinue)) { + Write-Error "The `makemsix` tool from the msix-packaging project is required to construct Appx package." + Write-Error "On macOS, you can install with Homebrew:" + Write-Error " brew install iinuwa/msix-packaging-tap/msix-packaging" + Exit 1 +} + +if (!(Get-Command osslsigncode -ErrorAction SilentlyContinue)) { + Write-Error "The `osslsigncode` tool is required to sign the Appx package." + Write-Error "On macOS, you can install with Homebrew:" + Write-Error " brew install osslsigncode" + Exit 1 +} + +if (!(Get-Command cargo-xwin -ErrorAction SilentlyContinue)) { + Write-Error "The `cargo-xwin` tool is required to cross-compile Windows native code." + Write-Error "You can install with cargo:" + Write-Error " cargo install --version 0.20.2 --locked cargo-xwin" + Exit 1 +} + +try { + +# Resolve certificate file before we change directories. +$CertificateFile = Get-Item $CertificatePath -ErrorAction SilentlyContinue + +cd $PSScriptRoot/.. + +if ($Beta) { + $electronConfigFile = Get-Item "./electron-builder.beta.json" +} +else { + $electronConfigFile = Get-Item "./electron-builder.json" +} + +$builderConfig = Get-Content $electronConfigFile | ConvertFrom-Json +$packageConfig = Get-Content package.json | ConvertFrom-Json +$manifestTemplate = Get-Content $builderConfig.appx.customManifestPath + +$srcDir = Get-Location +$assetsDir = Get-Item $builderConfig.directories.buildResources +$buildDir = Get-Item $builderConfig.directories.app +$outDir = Join-Path (Get-Location) ($builderConfig.directories.output ?? "dist") + +if ($Release) { + $buildConfiguration = "--release" +} +$arch = "$Architecture".ToLower() +$ext = "appx" +$version = Get-Date -Format "yyyy.M.d.1HHmm" +$productName = $builderConfig.productName +$artifactName = "${productName}-$($packageConfig.version)-${arch}.$ext" + +Write-Host "Building native code" +$rustTarget = switch ($arch) { + x64 { "x86_64-pc-windows-msvc" } + arm64 { "aarch64-pc-windows-msvc" } + default { + Write-Error "Unsupported architecture: $Architecture. Supported architectures are x64 and arm64" + Exit(1) + } +} +npm run build-native -- cross-platform $buildConfiguration "--target=$rustTarget" + +Write-Host "Building Javascript code" +if ($Release) { + npm run build +} +else { + npm run build:dev +} + +Write-Host "Cleaning output folder" +Remove-Item -Recurse -Force $outDir -ErrorAction Ignore + +Write-Host "Packaging Electron executable" +& npx electron-builder --config $electronConfigFile --publish never --dir --win --$arch + +cd $outDir +New-Item -Type Directory (Join-Path $outDir "appx") + +Write-Host "Building Appx directory structure" +$appxDir = (Join-Path $outDir appx/app) +if ($arch -eq "x64") { + Move-Item (Join-Path $outDir "win-unpacked") $appxDir +} +else { + Move-Item (Join-Path $outDir "win-${arch}-unpacked") $appxDir +} + +Write-Host "Copying Assets" +New-Item -Type Directory (Join-Path $outDir appx/assets) +Copy-Item $srcDir/resources/appx/* $outDir/appx/assets/ + +Write-Host "Building Appx manifest" +$translationMap = @{ + 'arch' = $arch + 'applicationId' = $builderConfig.appx.applicationId + 'displayName' = $productName + 'executable' = "app\${productName}.exe" + 'publisher' = $builderConfig.appx.publisher + 'publisherDisplayName' = $builderConfig.appx.publisherDisplayName + 'version' = $version +} + +$manifest = $manifestTemplate +$translationMap.Keys | ForEach-Object { + $manifest = $manifest.Replace("`${$_}", $translationMap[$_]) +} +$manifest | Out-File appx/AppxManifest.xml +$unsignedArtifactpath = [System.IO.Path]::GetFileNameWithoutExtension($artifactName) + "-unsigned.$ext" +Write-Host "Creating unsigned Appx" +makemsix pack -d appx -p $unsignedArtifactpath + +$outfile = Join-Path $outDir $unsignedArtifactPath +if ($null -eq $CertificatePath) { + Write-Warning "No Certificate specified. Not signing Appx." +} +elseif ($null -eq $CertificatePassword -and $null -eq $env:CERTIFICATE_PASSWORD) { + Write-Warning "No certificate password specified in CertificatePassword argument nor CERTIFICATE_PASSWORD environment variable. Not signing Appx." +} +else { + $cert = $CertificateFile + $pw = $null + if ($null -ne $CertificatePassword) { + $pw = ConvertFrom-SecureString -SecureString $CertificatePassword -AsPlainText + } else { + $pw = $env:CERTIFICATE_PASSWORD + } + $unsigned = $outfile + $outfile = (Join-Path $outDir $artifactName) + Write-Host "Signing $artifactName with $cert" + osslsigncode sign ` + -pkcs12 "$cert" ` + -pass "$pw" ` + -in $unsigned ` + -out $outfile + Remove-Item $unsigned +} + +$endTime = Get-Date +$elapsed = $endTime - $startTime +Write-Host "Successfully packaged $(Get-Item $outfile)" +Write-Host ("Finished at $($endTime.ToString('HH:mm:ss')) in $($elapsed.ToString('mm')) minutes and $($elapsed.ToString('ss')).$($elapsed.ToString('fff')) seconds") +} +finally { + Set-Location -Path $originalLocation +} diff --git a/apps/desktop/scripts/before-pack.js b/apps/desktop/scripts/before-pack.js new file mode 100644 index 00000000000..ca9bf924b2d --- /dev/null +++ b/apps/desktop/scripts/before-pack.js @@ -0,0 +1,31 @@ +/* eslint-disable no-console */ +/** @import { BeforePackContext } from 'app-builder-lib' */ +exports.default = run; + +/** + * @param {BeforePackContext} context + */ +async function run(context) { + console.log("## before pack"); + console.log("Stripping .node files that don't belong to this platform..."); + removeExtraNodeFiles(context); +} + +/** + * Removes Node files for platforms besides the current platform being packaged. + * + * @param {BeforePackContext} context + */ +function removeExtraNodeFiles(context) { + // When doing cross-platform builds, due to electron-builder limitiations, + // .node files for other platforms may be generated and unpacked, so we + // remove them manually here before signing and distributing. + const packagerPlatform = context.packager.platform.nodeName; + const platforms = ["darwin", "linux", "win32"]; + const fileFilter = context.packager.info._configuration.files[0].filter; + for (const platform of platforms) { + if (platform != packagerPlatform) { + fileFilter.push(`!node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-*.node`); + } + } +} diff --git a/apps/desktop/sign.js b/apps/desktop/sign.js index 6a42666c46f..f115e9b8097 100644 --- a/apps/desktop/sign.js +++ b/apps/desktop/sign.js @@ -1,22 +1,60 @@ /* eslint-disable @typescript-eslint/no-require-imports, no-console */ +const child_process = require("child_process"); exports.default = async function (configuration) { - if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && configuration.path.slice(-4) == ".exe") { + const ext = configuration.path.split(".").at(-1); + if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ["exe", "appx"].includes(ext)) { console.log(`[*] Signing file: ${configuration.path}`); - require("child_process").execSync( - `azuresigntool sign -v ` + - `-kvu ${process.env.SIGNING_VAULT_URL} ` + - `-kvi ${process.env.SIGNING_CLIENT_ID} ` + - `-kvt ${process.env.SIGNING_TENANT_ID} ` + - `-kvs ${process.env.SIGNING_CLIENT_SECRET} ` + - `-kvc ${process.env.SIGNING_CERT_NAME} ` + - `-fd ${configuration.hash} ` + - `-du ${configuration.site} ` + - `-tr http://timestamp.digicert.com ` + - `"${configuration.path}"`, + child_process.execFileSync( + "azuresigntool", + // prettier-ignore + [ + "sign", + "-v", + "-kvu", process.env.SIGNING_VAULT_URL, + "-kvi", process.env.SIGNING_CLIENT_ID, + "-kvt", process.env.SIGNING_TENANT_ID, + "-kvs", process.env.SIGNING_CLIENT_SECRET, + "-kvc", process.env.SIGNING_CERT_NAME, + "-fd", configuration.hash, + "-du", configuration.site, + "-tr", "http://timestamp.digicert.com", + configuration.path, + ], { stdio: "inherit", }, ); + } else if (process.env.ELECTRON_BUILDER_SIGN_CERT && ["exe", "appx"].includes(ext)) { + console.log(`[*] Signing file: ${configuration.path}`); + if (process.platform !== "win32") { + console.warn( + "Signing Windows executables on non-Windows platforms is not supported. Not signing.", + ); + return; + } + const certFile = process.env.ELECTRON_BUILDER_SIGN_CERT; + const certPw = process.env.ELECTRON_BUILDER_SIGN_CERT_PW; + if (!certPw) { + throw new Error( + "The certificate file password must be set in ELECTRON_BUILDER_SIGN_CERT_PW in order to sign files.", + ); + } + try { + child_process.execFileSync( + "signtool.exe", + ["sign", "/fd", "SHA256", "/a", "/f", certFile, "/p", certPw, configuration.path], + { + stdio: "inherit", + }, + ); + console.info(`Signed ${configuration.path} successfully.`); + } catch (error) { + throw new Error( + `Failed to sign ${configuration.path}: ${error.message}\n` + + `Check that ELECTRON_BUILDER_SIGN_CERT points to a valid PKCS12 file ` + + `and ELECTRON_BUILDER_SIGN_CERT_PW is correct.`, + ); + } } }; diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index f75f6ccdc20..e9b6dfdc9e5 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -114,6 +114,8 @@ const routes: Routes = [ authGuard, canAccessFeature(FeatureFlag.DesktopUiMigrationMilestone1, false, "new-vault", false), ], + // Needed to ensure feature flag changes are picked up on account switching + runGuardsAndResolvers: "always", }, { path: "send", diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index d1919c77bb5..01eb8c728e5 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -492,9 +492,8 @@ export class AppComponent implements OnInit, OnDestroy { this.loading = true; await this.syncService.fullSync(false); this.loading = false; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["vault"]); + // Force reload to ensure route guards are activated + await this.router.navigate(["vault"], { onSameUrlNavigation: "reload" }); } this.messagingService.send("finishSwitchAccount"); break; diff --git a/apps/desktop/src/app/layout/desktop-layout.component.html b/apps/desktop/src/app/layout/desktop-layout.component.html index 7e101ae1b6e..cb969f573fc 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.html +++ b/apps/desktop/src/app/layout/desktop-layout.component.html @@ -2,7 +2,7 @@ - + diff --git a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts index 2fb49e723ef..c838f47a06c 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts @@ -8,6 +8,7 @@ import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; import { DialogService, NavigationModule } from "@bitwarden/components"; import { GlobalStateProvider } from "@bitwarden/state"; +import { VaultFilterComponent } from "../../vault/app/vault-v3/vault-filter/vault-filter.component"; import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component"; import { DesktopLayoutComponent } from "./desktop-layout.component"; @@ -20,6 +21,13 @@ import { DesktopLayoutComponent } from "./desktop-layout.component"; }) class MockSendFiltersNavComponent {} +@Component({ + selector: "app-vault-filter", + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockVaultFiltersNavComponent {} + Object.defineProperty(window, "matchMedia", { writable: true, value: jest.fn().mockImplementation((query) => ({ @@ -59,8 +67,8 @@ describe("DesktopLayoutComponent", () => { ], }) .overrideComponent(DesktopLayoutComponent, { - remove: { imports: [SendFiltersNavComponent] }, - add: { imports: [MockSendFiltersNavComponent] }, + remove: { imports: [SendFiltersNavComponent, VaultFilterComponent] }, + add: { imports: [MockSendFiltersNavComponent, MockVaultFiltersNavComponent] }, }) .compileComponents(); @@ -93,4 +101,11 @@ describe("DesktopLayoutComponent", () => { expect(sendFiltersNav).toBeTruthy(); }); + + it("renders vault filters navigation component", () => { + const compiled = fixture.nativeElement; + const vaultFiltersNav = compiled.querySelector("app-vault-filter"); + + expect(vaultFiltersNav).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/app/layout/desktop-layout.component.ts b/apps/desktop/src/app/layout/desktop-layout.component.ts index 85339bc06c9..8d6ced2eb7d 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.ts @@ -5,6 +5,7 @@ import { PasswordManagerLogo } from "@bitwarden/assets/svg"; import { DialogService, LayoutComponent, NavigationModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +import { VaultFilterComponent } from "../../vault/app/vault-v3/vault-filter/vault-filter.component"; import { ExportDesktopComponent } from "../tools/export/export-desktop.component"; import { CredentialGeneratorComponent } from "../tools/generator/credential-generator.component"; import { ImportDesktopComponent } from "../tools/import/import-desktop.component"; @@ -22,6 +23,7 @@ import { DesktopSideNavComponent } from "./desktop-side-nav.component"; LayoutComponent, NavigationModule, DesktopSideNavComponent, + VaultFilterComponent, SendFiltersNavComponent, ], templateUrl: "./desktop-layout.component.html", diff --git a/apps/desktop/src/app/layout/header/desktop-header.component.html b/apps/desktop/src/app/layout/header/desktop-header.component.html index efee5e21d9b..ae578312535 100644 --- a/apps/desktop/src/app/layout/header/desktop-header.component.html +++ b/apps/desktop/src/app/layout/header/desktop-header.component.html @@ -1,21 +1,19 @@ -
- - - - + + + + - + - - - + + + - - - + + + - - - - -
+ + + + diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 752c09e2e92..a5a91c52e7e 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -1,10 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { APP_INITIALIZER, NgModule } from "@angular/core"; -import { Router } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { Subject, merge } from "rxjs"; -import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { @@ -36,6 +36,7 @@ import { } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService as PolicyServiceAbstraction, InternalPolicyService, @@ -107,6 +108,7 @@ import { SystemService } from "@bitwarden/common/platform/services/system.servic import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state"; import { SyncService } from "@bitwarden/common/platform/sync"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { DialogService, ToastService } from "@bitwarden/components"; import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; @@ -122,7 +124,15 @@ import { SessionTimeoutSettingsComponentService, } from "@bitwarden/key-management-ui"; import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; -import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; +import { + DefaultSshImportPromptService, + SshImportPromptService, + VaultFilterServiceAbstraction, + VaultFilterService, + RoutedVaultFilterService, + RoutedVaultFilterBridgeService, + VAULT_FILTER_BASE_ROUTE, +} from "@bitwarden/vault"; import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service"; import { DesktopAuthRequestAnsweringService } from "../../auth/services/auth-request-answering/desktop-auth-request-answering.service"; @@ -508,6 +518,34 @@ const safeProviders: SafeProvider[] = [ useClass: SessionTimeoutSettingsComponentService, deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyServiceAbstraction], }), + safeProvider({ + provide: VaultFilterServiceAbstraction, + useClass: VaultFilterService, + deps: [ + OrganizationService, + FolderService, + CipherServiceAbstraction, + PolicyServiceAbstraction, + I18nServiceAbstraction, + StateProvider, + CollectionService, + AccountServiceAbstraction, + ], + }), + safeProvider({ + provide: VAULT_FILTER_BASE_ROUTE, + useValue: "/new-vault", + }), + safeProvider({ + provide: RoutedVaultFilterService, + useClass: RoutedVaultFilterService, + deps: [ActivatedRoute], + }), + safeProvider({ + provide: RoutedVaultFilterBridgeService, + useClass: RoutedVaultFilterBridgeService, + deps: [Router, RoutedVaultFilterService, VaultFilterServiceAbstraction], + }), safeProvider({ provide: AuthRequestAnsweringService, useClass: DesktopAuthRequestAnsweringService, diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts index ab881e5b57b..f22b94974d1 100644 --- a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts @@ -6,7 +6,7 @@ import { BehaviorSubject } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NavigationModule } from "@bitwarden/components"; import { SendListFiltersService } from "@bitwarden/send-ui"; import { GlobalStateProvider } from "@bitwarden/state"; diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts index 28004f475e5..0dfdc1ee7c5 100644 --- a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts @@ -4,7 +4,7 @@ import { toSignal } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; import { filter, map, startWith } from "rxjs"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NavigationModule } from "@bitwarden/components"; import { SendListFiltersService } from "@bitwarden/send-ui"; import { I18nPipe } from "@bitwarden/ui-common"; 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 05c1332f1e7..eda740fa721 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 @@ -1,55 +1,25 @@ -
-
- - - @if (!disableSend()) { - - } - -
- - - - -
-
- - - @if (action() == "add" || action() == "edit") { - + + + @if (!disableSend()) { + } - - - @if (!action()) { - - } -
+ + + + + diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts index 713915e3cf7..a73a0534ff9 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts @@ -16,15 +16,20 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +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"; import { DialogService, ToastService } from "@bitwarden/components"; -import { SendItemsService, SendListFiltersService } from "@bitwarden/send-ui"; - -import { AddEditComponent } from "../send/add-edit.component"; +import { + SendItemsService, + SendListFiltersService, + DefaultSendFormConfigService, + SendAddEditDialogComponent, + SendFormConfig, +} from "@bitwarden/send-ui"; import { SendV2Component } from "./send-v2.component"; @@ -37,12 +42,34 @@ describe("SendV2Component", () => { let sendItemsService: MockProxy; let sendListFiltersService: MockProxy; let changeDetectorRef: MockProxy; + let sendFormConfigService: MockProxy; + let dialogService: MockProxy; + let environmentService: MockProxy; + let platformUtilsService: MockProxy; + let sendApiService: MockProxy; + let toastService: MockProxy; + let i18nService: MockProxy; beforeEach(async () => { sendService = mock(); accountService = mock(); policyService = mock(); changeDetectorRef = mock(); + sendFormConfigService = mock(); + dialogService = mock(); + environmentService = mock(); + platformUtilsService = mock(); + sendApiService = mock(); + toastService = mock(); + i18nService = mock(); + + // Setup environmentService mock + environmentService.getEnvironment.mockResolvedValue({ + getSendUrl: () => "https://send.bitwarden.com/#/", + } as any); + + // Setup i18nService mock + i18nService.t.mockImplementation((key: string) => key); // Mock SendItemsService with all required observables sendItemsService = mock(); @@ -71,15 +98,16 @@ describe("SendV2Component", () => { providers: [ provideNoopAnimations(), { provide: SendService, useValue: sendService }, - { provide: I18nService, useValue: mock() }, - { provide: PlatformUtilsService, useValue: mock() }, - { provide: EnvironmentService, useValue: mock() }, + { provide: I18nService, useValue: i18nService }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + { provide: EnvironmentService, useValue: environmentService }, { provide: SearchService, useValue: mockSearchService }, { provide: PolicyService, useValue: policyService }, { provide: LogService, useValue: mock() }, - { provide: SendApiService, useValue: mock() }, - { provide: DialogService, useValue: mock() }, - { provide: ToastService, useValue: mock() }, + { provide: SendApiService, useValue: sendApiService }, + { provide: DialogService, useValue: dialogService }, + { provide: DefaultSendFormConfigService, useValue: sendFormConfigService }, + { provide: ToastService, useValue: toastService }, { provide: AccountService, useValue: accountService }, { provide: SendItemsService, useValue: sendItemsService }, { provide: SendListFiltersService, useValue: sendListFiltersService }, @@ -97,7 +125,16 @@ describe("SendV2Component", () => { }, }, ], - }).compileComponents(); + }) + .overrideComponent(SendV2Component, { + set: { + providers: [ + { provide: DefaultSendFormConfigService, useValue: sendFormConfigService }, + { provide: PremiumUpgradePromptService, useValue: mock() }, + ], + }, + }) + .compileComponents(); fixture = TestBed.createComponent(SendV2Component); component = fixture.componentInstance; @@ -107,103 +144,83 @@ describe("SendV2Component", () => { expect(component).toBeTruthy(); }); - it("initializes with correct default action", () => { - expect(component.action()).toBe(""); - }); - describe("addSend", () => { - it("sets action to Add", async () => { - await component.addSend(SendType.Text); - expect(component.action()).toBe("add"); + beforeEach(() => { + jest.clearAllMocks(); }); - it("calls resetAndLoad on addEditComponent when component exists", async () => { - const mockAddEdit = mock(); - mockAddEdit.resetAndLoad.mockResolvedValue(); - jest.spyOn(component as any, "addEditComponent").mockReturnValue(mockAddEdit); + it("opens dialog with correct config for Text send", async () => { + const mockConfig = { mode: "add", sendType: SendType.Text } as SendFormConfig; + const mockDialogRef = { closed: of(true) }; - await component.addSend(SendType.Text); + sendFormConfigService.buildConfig.mockResolvedValue(mockConfig); + const openDrawerSpy = jest + .spyOn(SendAddEditDialogComponent, "openDrawer") + .mockReturnValue(mockDialogRef as any); - expect(mockAddEdit.resetAndLoad).toHaveBeenCalled(); + await component["addSend"](SendType.Text); + + expect(sendFormConfigService.buildConfig).toHaveBeenCalledWith( + "add", + undefined, + SendType.Text, + ); + expect(openDrawerSpy).toHaveBeenCalled(); + expect(openDrawerSpy.mock.calls[0][1]).toEqual({ + formConfig: mockConfig, + }); }); - it("does not throw when addEditComponent is null", async () => { - jest.spyOn(component as any, "addEditComponent").mockReturnValue(undefined); - await expect(component.addSend(SendType.Text)).resolves.not.toThrow(); - }); - }); + it("opens dialog with correct config for File send", async () => { + const mockConfig = { mode: "add", sendType: SendType.File } as SendFormConfig; + const mockDialogRef = { closed: of(true) }; - describe("closeEditPanel", () => { - it("resets action to None", () => { - component["action"].set("edit"); - component["sendId"].set("test-id"); + sendFormConfigService.buildConfig.mockResolvedValue(mockConfig); + const openDrawerSpy = jest + .spyOn(SendAddEditDialogComponent, "openDrawer") + .mockReturnValue(mockDialogRef as any); - component["closeEditPanel"](); + await component["addSend"](SendType.File); - expect(component["action"]()).toBe(""); - expect(component["sendId"]()).toBeNull(); - }); - }); - - describe("savedSend", () => { - it("selects the saved send", async () => { - jest.spyOn(component as any, "selectSend").mockResolvedValue(); - - const mockSend = new SendView(); - mockSend.id = "saved-send-id"; - - await component["savedSend"](mockSend); - - expect(component["selectSend"]).toHaveBeenCalledWith("saved-send-id"); + expect(sendFormConfigService.buildConfig).toHaveBeenCalledWith( + "add", + undefined, + SendType.File, + ); + expect(openDrawerSpy).toHaveBeenCalled(); + expect(openDrawerSpy.mock.calls[0][1]).toEqual({ + formConfig: mockConfig, + }); }); }); describe("selectSend", () => { - it("sets action to Edit and updates sendId", async () => { - await component["selectSend"]("new-send-id"); - - expect(component["action"]()).toBe("edit"); - expect(component["sendId"]()).toBe("new-send-id"); + beforeEach(() => { + jest.clearAllMocks(); }); - it("updates addEditComponent when it exists", async () => { - const mockAddEdit = mock(); - mockAddEdit.refresh.mockResolvedValue(); - jest.spyOn(component as any, "addEditComponent").mockReturnValue(mockAddEdit); + it("opens dialog with correct config for editing send", async () => { + const mockConfig = { mode: "edit", sendId: "test-send-id" } as SendFormConfig; + const mockDialogRef = { closed: of(true) }; + + sendFormConfigService.buildConfig.mockResolvedValue(mockConfig); + const openDrawerSpy = jest + .spyOn(SendAddEditDialogComponent, "openDrawer") + .mockReturnValue(mockDialogRef as any); await component["selectSend"]("test-send-id"); - expect(mockAddEdit.sendId).toBe("test-send-id"); - expect(mockAddEdit.refresh).toHaveBeenCalled(); - }); - - it("does not reload if same send is already selected in edit mode", async () => { - const mockAddEdit = mock(); - jest.spyOn(component as any, "addEditComponent").mockReturnValue(mockAddEdit); - component["sendId"].set("same-id"); - component["action"].set("edit"); - - await component["selectSend"]("same-id"); - - expect(mockAddEdit.refresh).not.toHaveBeenCalled(); - }); - - it("reloads if selecting different send", async () => { - const mockAddEdit = mock(); - mockAddEdit.refresh.mockResolvedValue(); - jest.spyOn(component as any, "addEditComponent").mockReturnValue(mockAddEdit); - component["sendId"].set("old-id"); - component["action"].set("edit"); - - await component["selectSend"]("new-id"); - - expect(mockAddEdit.refresh).toHaveBeenCalled(); + expect(sendFormConfigService.buildConfig).toHaveBeenCalledWith("edit", "test-send-id"); + expect(openDrawerSpy).toHaveBeenCalled(); + expect(openDrawerSpy.mock.calls[0][1]).toEqual({ + formConfig: mockConfig, + }); }); }); describe("onEditSend", () => { it("selects the send for editing", async () => { - jest.spyOn(component as any, "selectSend").mockResolvedValue(); + jest.spyOn(component as any, "selectSend").mockResolvedValue(undefined); const mockSend = new SendView(); mockSend.id = "edit-send-id"; @@ -212,4 +229,25 @@ describe("SendV2Component", () => { expect(component["selectSend"]).toHaveBeenCalledWith("edit-send-id"); }); }); + + describe("onCopySend", () => { + it("copies send link to clipboard and shows success toast", async () => { + const mockSend = { + accessId: "test-access-id", + urlB64Key: "test-key", + } as SendView; + + await component["onCopySend"](mockSend); + + expect(environmentService.getEnvironment).toHaveBeenCalled(); + expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith( + "https://send.bitwarden.com/#/test-access-id/test-key", + ); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: expect.any(String), + }); + }); + }); }); 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 6a44713d309..7fab0cb6702 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 @@ -4,14 +4,11 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - computed, effect, inject, - signal, - viewChild, } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; -import { combineLatest, map, switchMap } from "rxjs"; +import { combineLatest, map, switchMap, lastValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -22,9 +19,10 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { SendId } from "@bitwarden/common/types/guid"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, DialogService, ToastService } from "@bitwarden/components"; import { @@ -32,34 +30,24 @@ import { SendItemsService, SendListComponent, SendListState, + SendAddEditDialogComponent, + DefaultSendFormConfigService, } from "@bitwarden/send-ui"; import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; import { DesktopHeaderComponent } from "../../layout/header"; -import { AddEditComponent } from "../send/add-edit.component"; - -const Action = Object.freeze({ - /** No action is currently active. */ - None: "", - /** The user is adding a new Send. */ - Add: "add", - /** The user is editing an existing Send. */ - Edit: "edit", -} as const); - -type Action = (typeof Action)[keyof typeof Action]; @Component({ selector: "app-send-v2", imports: [ JslibModule, ButtonModule, - AddEditComponent, SendListComponent, NewSendDropdownV2Component, DesktopHeaderComponent, ], providers: [ + DefaultSendFormConfigService, { provide: PremiumUpgradePromptService, useClass: DesktopPremiumUpgradePromptService, @@ -69,22 +57,17 @@ type Action = (typeof Action)[keyof typeof Action]; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendV2Component { - protected readonly addEditComponent = viewChild(AddEditComponent); - - protected readonly sendId = signal(null); - protected readonly action = signal(Action.None); - private readonly selectedSendTypeOverride = signal(undefined); - + private sendFormConfigService = inject(DefaultSendFormConfigService); private sendItemsService = inject(SendItemsService); private policyService = inject(PolicyService); private accountService = inject(AccountService); private i18nService = inject(I18nService); private platformUtilsService = inject(PlatformUtilsService); private environmentService = inject(EnvironmentService); - private logService = inject(LogService); private sendApiService = inject(SendApiService); private dialogService = inject(DialogService); private toastService = inject(ToastService); + private logService = inject(LogService); private cdr = inject(ChangeDetectorRef); protected readonly filteredSends = toSignal(this.sendItemsService.filteredAndSortedSends$, { @@ -137,53 +120,27 @@ export class SendV2Component { } protected async addSend(type: SendType): Promise { - this.action.set(Action.Add); - this.sendId.set(null); - this.selectedSendTypeOverride.set(type); + const formConfig = await this.sendFormConfigService.buildConfig("add", undefined, type); - const component = this.addEditComponent(); - if (component) { - await component.resetAndLoad(); - } + const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, { + formConfig, + }); + + await lastValueFrom(dialogRef.closed); } - protected closeEditPanel(): void { - this.action.set(Action.None); - this.sendId.set(null); - this.selectedSendTypeOverride.set(undefined); + protected async selectSend(sendId: SendId): Promise { + const formConfig = await this.sendFormConfigService.buildConfig("edit", sendId); + + const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, { + formConfig, + }); + + await lastValueFrom(dialogRef.closed); } - protected async savedSend(send: SendView): Promise { - await this.selectSend(send.id); - } - - protected async selectSend(sendId: string): Promise { - if (sendId === this.sendId() && this.action() === Action.Edit) { - return; - } - this.action.set(Action.Edit); - this.sendId.set(sendId); - const component = this.addEditComponent(); - if (component) { - component.sendId = sendId; - await component.refresh(); - } - } - - protected readonly selectedSendType = computed(() => { - const action = this.action(); - const typeOverride = this.selectedSendTypeOverride(); - - if (action === Action.Add && typeOverride !== undefined) { - return typeOverride; - } - - const sendId = this.sendId(); - return this.filteredSends().find((s) => s.id === sendId)?.type; - }); - protected async onEditSend(send: SendView): Promise { - await this.selectSend(send.id); + await this.selectSend(send.id as SendId); } protected async onCopySend(send: SendView): Promise { @@ -219,11 +176,6 @@ export class SendV2Component { title: null, message: this.i18nService.t("removedPassword"), }); - - if (this.sendId() === send.id) { - this.sendId.set(null); - await this.selectSend(send.id); - } } catch (e) { this.logService.error(e); } @@ -247,7 +199,5 @@ export class SendV2Component { title: null, message: this.i18nService.t("deletedSend"), }); - - this.closeEditPanel(); } } diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index cb186f31d35..5e2a0f41507 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index a33c6b301b6..7ff2f54321c 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index d3cdbba9e23..46958d5ae3d 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -100,8 +100,72 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { - "message": "New", + "message": "Yeni", "description": "for adding new items" }, "newUri": { @@ -2416,7 +2480,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": "\"Send\" keçidini kopyala", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLinkToClipboard": { @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "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." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Əskik veb sayt" }, @@ -4040,14 +4110,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": "Heç bir axtarış nəticəsi qayıtmadı" }, "sendsBodyNoItems": { "message": "İstənilən platformada faylları və veriləri hər kəslə güvənli şəkildə paylaşın. Məlumatlarınız, ifşa olunmamaq üçün ucdan-uca şifrələnmiş qalacaq.", "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": "Filtrləri təmizləyin və ya başqa bir axtarış terminini sınayın" }, "generatorNudgeTitle": { "message": "Cəld parol yaradın" @@ -4268,7 +4338,7 @@ "message": "Yazma qısayolu" }, "editAutotypeKeyboardModifiersDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." + "message": "Aşağıdakı dəyişdiricilərdən birini və ya ikisini daxil edin: Ctrl, Alt, Win və bir hərf." }, "invalidShortcut": { "message": "Yararsız qısayol" @@ -4307,7 +4377,7 @@ "message": "Arxivdən çıxart" }, "archived": { - "message": "Archived" + "message": "Arxivləndi" }, "itemsInArchive": { "message": "Arxivdəki elementlər" @@ -4331,19 +4401,19 @@ "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?" }, "unArchiveAndSave": { - "message": "Unarchive and save" + "message": "Arxivdən çıxart və saxla" }, "restartPremium": { - "message": "Restart Premium" + "message": "\"Premium\"u yenidən başlat" }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Premium abunəliyiniz bitdi" }, "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": "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." }, "itemRestored": { - "message": "Item has been restored" + "message": "Element bərpa edildi" }, "zipPostalCodeLabel": { "message": "ZIP / Poçt kodu" diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index 4f441d20781..eefbfc46874 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index 10702ea4aa9..7c772655b31 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "Изпращането ще бъде окончателно изтрито на тази дата.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "Файл за споделяне" + }, + "hideTextByDefault": { + "message": "Скриване на текста по подразбиране" + }, + "hideYourEmail": { + "message": "Скриване на Вашата е-поща от получателите." + }, + "limitSendViews": { + "message": "Ограничаване на преглежданията" + }, + "limitSendViewsCount": { + "message": "Остават $ACCESSCOUNT$ преглеждания", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "Никой няма да може да преглежда това Изпращане след достигане на ограничението.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Лична бележка" + }, + "sendDetails": { + "message": "Подробности за Изпращането", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDescV3": { + "message": "Добавете незадължителна парола, с която получателите да имат достъп до това Изпращане.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Текст за споделяне" + }, + "newItemHeaderTextSend": { + "message": "Ново текстово Изпращане", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "Ново файлово Изпращане", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Редактиране на текстовото Изпращане", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Редактиране на файловото Изпращане", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Наистина ли искате да изтриете завинаги това Изпращане?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Ново", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Този елемент за вписване е в риск и в него липсва уеб сайт. Добавете уеб сайт и сменете паролата, за по-добра сигурност." }, + "vulnerablePassword": { + "message": "Уязвима парола." + }, + "changeNow": { + "message": "Промяна сега" + }, "missingWebsite": { "message": "Липсващ уеб сайт" }, diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index 607418bcb46..6219f374ae1 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index 9f13b809760..622476d1836 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index 1b03ad6fa1e..864dce62be8 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index 437f42f840f..cb89a325ab8 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "Tento Send bude trvale smazán v určené datum.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "Soubor ke sdílení" + }, + "hideTextByDefault": { + "message": "Ve výchozím nastavení skrýt text" + }, + "hideYourEmail": { + "message": "Skryje Vaši e-mailovou adresu před zobrazením." + }, + "limitSendViews": { + "message": "Omezit zobrazení" + }, + "limitSendViewsCount": { + "message": "Zbývá $ACCESSCOUNT$ zobrazení", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "Po dosažení limitu nebude nikdo moci zobrazit tento Send.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Soukromá poznámka" + }, + "sendDetails": { + "message": "Podrobnosti Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDescV3": { + "message": "Přidá volitelné heslo pro příjemce pro přístup k tomuto Send.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text ke sdílení" + }, + "newItemHeaderTextSend": { + "message": "Nový textový Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "Nový Send se soubory", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Upravit textový Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Upravit Send se soubory", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Opravdu chcete tento Send trvale smazat?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Nová", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Tyto přihlašovací údaje jsou ohrožené a chybí jim webová stránka. Přidejte webovou stránku a změňte heslo pro větší bezpečnost." }, + "vulnerablePassword": { + "message": "Zranitelné heslo." + }, + "changeNow": { + "message": "Změnit nyní" + }, "missingWebsite": { "message": "Chybějící webová stránka" }, diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index f04f6625529..512426c218d 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index f022c4cee33..d523859005e 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 2783b39ca69..60480b2540f 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "Das Send wird an diesem Datum dauerhaft gelöscht.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "Zu teilende Datei" + }, + "hideTextByDefault": { + "message": "Text standardmäßig ausblenden" + }, + "hideYourEmail": { + "message": "Verberge deine E-Mail-Adresse vor Betrachtern." + }, + "limitSendViews": { + "message": "Ansichten begrenzen" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ Ansichten übrig", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "Nach Erreichen des Limits kann niemand mehr dieses Send sehen.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private Notiz" + }, + "sendDetails": { + "message": "Send-Details", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDescV3": { + "message": "Füge ein optionales Passwort hinzu, mit dem Empfänger auf dieses Send zugreifen können.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Zu teilender Text" + }, + "newItemHeaderTextSend": { + "message": "Neues Text-Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "Neues Datei-Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Text-Send bearbeiten", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Datei-Send bearbeiten", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Bist du sicher, dass du dieses Send dauerhaft löschen möchtest?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Neu", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Diese Zugangsdaten sind gefährdet und es fehlt eine Website. Füge eine Website hinzu und ändere das Passwort für mehr Sicherheit." }, + "vulnerablePassword": { + "message": "Gefährdetes Passwort." + }, + "changeNow": { + "message": "Jetzt ändern" + }, "missingWebsite": { "message": "Fehlende Website" }, diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index 9a4d2b736be..ed7e21d88b8 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index b00233457ec..f9947e16692 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4010,6 +4074,12 @@ }, "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" }, "missingWebsite": { "message": "Missing website" @@ -4327,8 +4397,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 f7020c63bf1..b74fb9de282 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index d4e497f41d3..5d4d48b49c0 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index ab07b9db9af..94be4656381 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index af61f7a97a0..8426b5fe51b 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index b013a55ffd7..6410076e603 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index 49b7bad76de..c0665dd472d 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index 10be871914f..b0c4940ef0f 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "این ورود در معرض خطر است و فاقد وب‌سایت می‌باشد. برای امنیت بیشتر، یک وب‌سایت اضافه کنید و رمز عبور را تغییر دهید." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "وب‌سایت وجود ندارد" }, diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index 2c2275f3afa..ac67919ba51 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index ff7562680e1..e62551edb98 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index 566e6fc7429..424fb9cc124 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Cet identifiant est à risques et manque un site web. Ajoutez un site web et changez le mot de passe pour une meilleure sécurité." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Site Web manquant" }, diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index 5a5a1715d7e..33a721bf8b0 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index 5ce8db992f2..876673d3aab 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "כניסה זו נמצאת בסיכון וחסר בה אתר אינטרנט. הוסף אתר אינטרנט ושנה את הסיסמה לאבטחה חזקה יותר." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "לא נמצא אתר אינטרנט" }, diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index 4c376c957d5..ddb6793f64f 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index 71aa2a5a67a..fdb0a57dc1b 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "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." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Nedostaje web-stranica" }, diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index fd0667d7aa8..659360ec4a5 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "A Send véglegesen törölve lesz ebben az időpontban.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "Megosztandó fájl" + }, + "hideTextByDefault": { + "message": "Szöveg elrejtése alapértelmezetten" + }, + "hideYourEmail": { + "message": "Saját email cím elrejtése a megtekintések elől." + }, + "limitSendViews": { + "message": "Megtekintések korlátozása" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ megtekintés maradt.", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "Senki sem tudja megtekinteni ezt a Send elemet a korlát elérése után.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Személyes jegyzet" + }, + "sendDetails": { + "message": "Send részletek", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDescV3": { + "message": "Adjunk meg egy opcionális jelszót a címzetteknek a Send eléréséhez.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Megosztandó szöveg" + }, + "newItemHeaderTextSend": { + "message": "Új szöveges Send elem", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "Új fájl Send elem", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Send szöveg szerkesztése", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Send fájl szerkesztése", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Biztosan véglegesen törlésre kerüljön ez a Send elem?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Új", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Ez a bejelentkezés veszélyben van és hiányzik egy webhely. Adjunk hozzá egy webhelyet és módosítsuk a jelszót az erősebb biztonság érdekében." }, + "vulnerablePassword": { + "message": "A jelszó sérülékeny." + }, + "changeNow": { + "message": "Módosítás most" + }, "missingWebsite": { "message": "Hiányzó webhely" }, diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index eb84f8bd747..ed02ed102bd 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index d27efd0789a..a328412149d 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Nuovo", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "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." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Sito Web mancante" }, @@ -4268,7 +4338,7 @@ "message": "Premi i tasti da impostare per la scorciatoia" }, "editAutotypeKeyboardModifiersDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." + "message": "Includi uno o due tasti modificatori a scelta tra Ctrl, Alt e Win." }, "invalidShortcut": { "message": "Scorciatoia non valida" diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index 24395bf30d7..79477e9a8ae 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index 831367b12a8..8c0e9cf659c 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index 5a5a1715d7e..33a721bf8b0 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index d7602b05af2..84aa5e53262 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index 700439e6030..46da23b2b4a 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index e959141e0c9..b2b18baf647 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index 12e548f50b9..11348ebb5f1 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "Send šajā datumā tiks neatgriezeniski izdzēsts.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "Datne, ko kopīgot" + }, + "hideTextByDefault": { + "message": "Pēc noklusējuma paslēpt tekstu" + }, + "hideYourEmail": { + "message": "Paslēpt e-pasta adresi no apskatītājiem." + }, + "limitSendViews": { + "message": "Ierobežot skatījumus" + }, + "limitSendViewsCount": { + "message": "Atlikuši $ACCESSCOUNT$ skatījumi", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "Neviens nevar apskatīt šo Send pēc tam, kad ir sasniegts ierobežojums.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Personiska piezīme" + }, + "sendDetails": { + "message": "Informācija par Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDescV3": { + "message": "Pēc izvēles var pievienot paroli, lai saņēmēji varētu piekļūt šim Send.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Kopīgojamais teksts" + }, + "newItemHeaderTextSend": { + "message": "Jauns teksta Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "Jauns datnes Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Labot teksta Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Labot datnes Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Vai tiešām neatgriezeniski izdzēst šo Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Jauns", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Šis pieteikšanās vienums ir pakļauts riskam, un tam nav norādīta tīmekļvietne. Lielākai drošībai jāpievieno tīmekļvietne un jānomaina parole." }, + "vulnerablePassword": { + "message": "Ievainojama parole." + }, + "changeNow": { + "message": "Mainīt tagad" + }, "missingWebsite": { "message": "Nav norādīta tīmekļvietne" }, diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index fea8b6d97a5..53ef1cd0c62 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index f8eb1f682f9..c13f70c64d7 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index 5a5a1715d7e..33a721bf8b0 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index 189044c4c40..bc32f53140a 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index 508e4bbdfbc..be6a2cdebb5 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index 8d688575099..846c4261187 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index bfbac0c2e65..9b510783286 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Nieuw", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Deze login is in gevaar en mist een website. Voeg een website toe en verander het wachtwoord voor een sterkere beveiliging." }, + "vulnerablePassword": { + "message": "Kwetsbaar wachtwoord." + }, + "changeNow": { + "message": "Nu wijzigen" + }, "missingWebsite": { "message": "Ontbrekende website" }, diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index 9119a018cbd..8d3a9b2197a 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index 4ffca3c1e6e..5e470bb8125 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index 50a39b462d9..b6d51e0627a 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "W tym dniu wysyłka zostanie trwale usunięta.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "Plik wysyłki" + }, + "hideTextByDefault": { + "message": "Ukryj domyślnie tekst wysyłki" + }, + "hideYourEmail": { + "message": "Ukryj mój adres e-mail przed odbiorcami." + }, + "limitSendViews": { + "message": "Maksymalna liczba wyświetleń" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "Po osiągnięciu limitu wysyłka będzie niedostępna.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Prywatna notatka" + }, + "sendDetails": { + "message": "Szczegóły wysyłki", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDescV3": { + "message": "Zabezpiecz wysyłkę opcjonalnym hasłem.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Tekst wysyłki" + }, + "newItemHeaderTextSend": { + "message": "Nowa wysyłka tekstu", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "Nowa wysyłka pliku", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edytuj tekst wysyłki", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edytuj plik wysyłki", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Czy na pewno chcesz usunąć trwale wysyłkę?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Nowy element", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Dane logowania są zagrożone i nie zawierają strony internetowej. Dodaj stronę internetową i zmień hasło." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Zmień teraz" + }, "missingWebsite": { "message": "Brak strony internetowej" }, diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index a0696b63c1e..31fe1915faa 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "O Send será apagado permanentemente nesta data.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "Arquivo para compartilhar" + }, + "hideTextByDefault": { + "message": "Ocultar texto por padrão" + }, + "hideYourEmail": { + "message": "Ocultar seu endereço de e-mail dos visualizadores." + }, + "limitSendViews": { + "message": "Limitar visualizações" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ visualizações restantes", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "Ninguém poderá visualizar este Send depois que o limite foi atingido.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Anotação privada" + }, + "sendDetails": { + "message": "Detalhes do Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDescV3": { + "message": "Adicione uma senha opcional para que os destinatários acessem este Send.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Texto para compartilhar" + }, + "newItemHeaderTextSend": { + "message": "Novo Send de texto", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "Novo Send de arquivo", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Editar Send de texto", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Editar Send de arquivo", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Tem certeza de que quer apagar este Send permanentemente?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Criar", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Esta credencial está em risco e está sem um site. Adicione um site e altere a senha para segurança melhor." }, + "vulnerablePassword": { + "message": "Senha vulnerável." + }, + "changeNow": { + "message": "Alterar agora" + }, "missingWebsite": { "message": "Site ausente" }, diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index 3b6a818977e..4f35aa7bbee 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "O Send será permanentemente eliminado nesta data.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "Ficheiro a partilhar" + }, + "hideTextByDefault": { + "message": "Ocultar texto por predefinição" + }, + "hideYourEmail": { + "message": "Oculte o seu endereço de e-mail dos visualizadores." + }, + "limitSendViews": { + "message": "Limitar visualizações" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ visualizações restantes", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "Ninguém poderá ver este Send depois de o limite ser atingido.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Nota privada" + }, + "sendDetails": { + "message": "Detalhes do Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDescV3": { + "message": "Adicione uma palavra-passe opcional para os destinatários acederem a este Send.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Texto a partilhar" + }, + "newItemHeaderTextSend": { + "message": "Novo Send de texto", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "Novo Send de ficheiro", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Editar Send de texto", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Editar Send de ficheiro", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Tem a certeza de que pretende eliminar permanentemente este Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Novo", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Esta credencial está em risco e não tem um site. Adicione um site e altere a palavra-passe para uma segurança mais forte." }, + "vulnerablePassword": { + "message": "Palavra-passe vulnerável." + }, + "changeNow": { + "message": "Alterar agora" + }, "missingWebsite": { "message": "Site em falta" }, diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index b188aa96d1c..05eb279529f 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index 6d7b8bf1c23..5c9a0a201fe 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Новый", "description": "for adding new items" @@ -1780,19 +1844,19 @@ "message": "Экспорт из" }, "exportNoun": { - "message": "Export", + "message": "Экспорт", "description": "The noun form of the word Export" }, "exportVerb": { - "message": "Export", + "message": "Экспортировать", "description": "The verb 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": { @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Этот логин подвержен риску и у него отсутствует веб-сайт. Добавьте веб-сайт и смените пароль для большей безопасности." }, + "vulnerablePassword": { + "message": "Уязвимый пароль." + }, + "changeNow": { + "message": "Изменить сейчас" + }, "missingWebsite": { "message": "Отсутствует сайт" }, diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index 37f2897c919..bcbe7ae5353 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index b3398629da3..c0ec24a17d5 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "Send bude natrvalo odstránený v tento deň.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "Súbor, ktorý chcete zdieľať" + }, + "hideTextByDefault": { + "message": "V predvolenom nastavení skryť text" + }, + "hideYourEmail": { + "message": "Skryť moju e-mailovú adresu pri zobrazení." + }, + "limitSendViews": { + "message": "Obmedziť zobrazenia" + }, + "limitSendViewsCount": { + "message": "Zostávajúce zobrazenia: $ACCESSCOUNT$", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "Po dosiahnutí limitu si tento Send nemôže nikto zobraziť.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Súkromná poznámka" + }, + "sendDetails": { + "message": "Podrobnosti o Sende", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDescV3": { + "message": "Pridajte voliteľné heslo pre príjemcov na prístup k tomuto Sendu.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text, ktorý chcete zdieľať" + }, + "newItemHeaderTextSend": { + "message": "Nový textový Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "Nový súborový Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Upraviť textový Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Upraviť súborový Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Naozaj chcete natrvalo odstrániť tento Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Nové", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Toto prihlásenie je v ohrození a chýba mu webová stránka. Pridajte webovú stránku a zmeňte heslo na silnejšie zabezpečenie." }, + "vulnerablePassword": { + "message": "Zraniteľné heslo." + }, + "changeNow": { + "message": "Zmeniť teraz" + }, "missingWebsite": { "message": "Chýbajúca webová stránka" }, diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index 6fa6d25285f..54a01e0e77d 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index 885ed8410db..b2bc5561459 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Ова пријава је ризична и недостаје веб локација. Додајте веб страницу и промените лозинку за јачу сигурност." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Недостаје веб страница" }, diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index f96fff1153c..91330769874 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "Fil att dela" + }, + "hideTextByDefault": { + "message": "Dölj text som standard" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Begränsa antalet visningar" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ visningar kvar", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Privat anteckning" + }, + "sendDetails": { + "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.", + "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", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Ny", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Denna inloggning är utsatt för risk och saknar en webbplats. Lägg till en webbplats och ändra lösenordet för ökad säkerhet." }, + "vulnerablePassword": { + "message": "Sårbart lösenord." + }, + "changeNow": { + "message": "Ändra nu" + }, "missingWebsite": { "message": "Saknar webbplats" }, @@ -4307,7 +4377,7 @@ "message": "Avarkivera" }, "archived": { - "message": "Archived" + "message": "Arkiverade" }, "itemsInArchive": { "message": "Objekt i arkivet" @@ -4331,16 +4401,16 @@ "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?" }, "unArchiveAndSave": { - "message": "Unarchive and save" + "message": "Avarkivera och spara" }, "restartPremium": { "message": "Starta om Premium" }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Ditt Premium-abonnemang avslutades" }, "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": "För att återfå åtkomst till ditt arkiv, starta om Premium-abonnemanget. Om du redigerar detaljer för ett arkiverat objekt innan du startar om kommer det att flyttas tillbaka till ditt valv." }, "itemRestored": { "message": "Objektet har återställts" diff --git a/apps/desktop/src/locales/ta/messages.json b/apps/desktop/src/locales/ta/messages.json index d95440ca1a2..722110dd8ab 100644 --- a/apps/desktop/src/locales/ta/messages.json +++ b/apps/desktop/src/locales/ta/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index 5a5a1715d7e..33a721bf8b0 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index b87ed307efc..23c502ab350 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index 59995a02096..1626ebe8cab 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "Bu Send belirtilen tarihte kalıcı olacak silinecek.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "Paylaşılacak dosya" + }, + "hideTextByDefault": { + "message": "Metni varsayılan olarak gizle" + }, + "hideYourEmail": { + "message": "E-posta adresimi Send'i görüntüleyenlerden gizle." + }, + "limitSendViews": { + "message": "Gösterimi sınırla" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ gösterim kaldı", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "Bu sınıra ulaşıldıktan sonra bu Send'i kimse göremez.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Özel not" + }, + "sendDetails": { + "message": "Send ayrıntıları", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDescV3": { + "message": "Alıcıların bu Send'e erişmesi için isterseniz parola ekleyebilirsiniz.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Paylaşılacak metin" + }, + "newItemHeaderTextSend": { + "message": "Yeni Send metni", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "Yeni Send dosyası", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Send metnini düzenle", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Send dosyasını düzenle", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Bu Send'i kalıcı olarak silmek istediğinizden emin misiniz?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "Yeni", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Bu hesap risk altında ve web sitesi eksik. Bir web sitesi ekleyin ve güvenliğinizi artırmak için parolayı değiştirin." }, + "vulnerablePassword": { + "message": "Güvensiz parola." + }, + "changeNow": { + "message": "Şimdi değiştir" + }, "missingWebsite": { "message": "Web sitesi eksik" }, diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index ed9c76b6ecd..65f2bb6f66f 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Цей запис ризикований, і не має адреси вебсайту. Додайте адресу вебсайту і змініть пароль для вдосконалення безпеки." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Немає вебсайту" }, diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index ff711c154bc..7fc635bfae3 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "File to share" + }, + "hideTextByDefault": { + "message": "Hide text by default" + }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "Private note" + }, + "sendDetails": { + "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.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, + "newItemHeaderTextSend": { + "message": "New Text Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "New File Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "Edit Text Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "Edit File Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "New", "description": "for adding new items" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "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." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Thiếu trang web" }, diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 028e7836c41..3dd57eac4da 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -100,6 +100,70 @@ } } }, + "deletionDateDescV2": { + "message": "此 Send 将在此日期后被永久删除。", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "要分享的文件" + }, + "hideTextByDefault": { + "message": "默认隐藏文本" + }, + "hideYourEmail": { + "message": "对查看者隐藏您的电子邮件地址。" + }, + "limitSendViews": { + "message": "限制查看次数" + }, + "limitSendViewsCount": { + "message": "剩余 $ACCESSCOUNT$ 次查看次数", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "达到限额后,任何人无法查看此 Send。", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "私密备注" + }, + "sendDetails": { + "message": "Send 详细信息", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDescV3": { + "message": "添加一个用于接收者访问此 Send 的可选密码。", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "要分享的文本" + }, + "newItemHeaderTextSend": { + "message": "新增文本 Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "新增文件 Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "编辑文本 Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "编辑文件 Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "确定要永久删除此 Send 吗?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { "message": "新增", "description": "for adding new items" @@ -2574,7 +2638,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为「$ACTION$」。", "placeholders": { "hours": { "content": "$1", @@ -3484,7 +3548,7 @@ "message": "清除全部" }, "plusNMore": { - "message": "另外 $QUANTITY$ 个", + "message": "还有 $QUANTITY$ 个", "placeholders": { "quantity": { "content": "$1", @@ -3982,7 +4046,7 @@ "message": "此应用程序已存在一个通行密钥。" }, "applicationDoesNotSupportDuplicates": { - "message": "此应用程序不支持重复项。" + "message": "此应用程序不支持重复。" }, "closeThisWindow": { "message": "关闭此窗口" @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "此登录存在风险且缺少网站。请添加网站并更改密码以增强安全性。" }, + "vulnerablePassword": { + "message": "易受攻击的密码。" + }, + "changeNow": { + "message": "立即更改" + }, "missingWebsite": { "message": "缺少网站" }, @@ -4268,7 +4338,7 @@ "message": "输入快捷键" }, "editAutotypeKeyboardModifiersDescription": { - "message": "包含以下修饰键中的一个或两个:Ctrl、Alt、Win,以及一个字母。" + "message": "包含以下修饰键中的一个或两个:Ctrl、Alt、Win,以及一个字母键。" }, "invalidShortcut": { "message": "无效的快捷键" diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index 7a3ff01e115..43796c5b6af 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -100,8 +100,72 @@ } } }, + "deletionDateDescV2": { + "message": "此 Send 將在指定的日期後被永久刪除。", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "fileToShare": { + "message": "要分享的文件" + }, + "hideTextByDefault": { + "message": "默認隱藏文字" + }, + "hideYourEmail": { + "message": "對查看者隱藏您的電子郵件地址。" + }, + "limitSendViews": { + "message": "限制查看" + }, + "limitSendViewsCount": { + "message": "剩餘 $ACCESSCOUNT$ 次查看次數", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "limitSendViewsHint": { + "message": "在達到限額後,沒有人能查看此 Send。", + "description": "Displayed under the limit views field on Send" + }, + "privateNote": { + "message": "私人備註" + }, + "sendDetails": { + "message": "Send 詳細資訊", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDescV3": { + "message": "新增一個用於收件人存取此 Send 的可選密碼。", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "要分享的文字" + }, + "newItemHeaderTextSend": { + "message": "新增文字 Send", + "description": "Header for new text send" + }, + "newItemHeaderFileSend": { + "message": "新增檔案 Send", + "description": "Header for new file send" + }, + "editItemHeaderTextSend": { + "message": "編輯文字 Send", + "description": "Header for edit text send" + }, + "editItemHeaderFileSend": { + "message": "編輯檔案 Send", + "description": "Header for edit file send" + }, + "deleteSendPermanentConfirmation": { + "message": "您確定要永久刪除此 Send 嗎?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "new": { - "message": "New", + "message": "新增", "description": "for adding new items" }, "newUri": { @@ -2416,7 +2480,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": "複製 Send 連結", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLinkToClipboard": { @@ -4011,6 +4075,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "此登入資訊存在風險,且缺少網站。請新增網站並變更密碼以提升安全性。" }, + "vulnerablePassword": { + "message": "有安全疑慮的密碼。" + }, + "changeNow": { + "message": "立即變更" + }, "missingWebsite": { "message": "缺少網站" }, @@ -4040,14 +4110,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": "沒有搜尋結果" }, "sendsBodyNoItems": { "message": "安全的和任何人及任何平臺分享檔案及資料。您的資料會受到端對端加密的保護。", "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": "清除過濾器或更換另一個搜尋條件" }, "generatorNudgeTitle": { "message": "快速建立密碼" @@ -4268,7 +4338,7 @@ "message": "輸入快捷鍵" }, "editAutotypeKeyboardModifiersDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." + "message": "請包含以下修飾鍵之一或兩個:Ctrl、Alt、Win 或 Shift,再加上一個字母。" }, "invalidShortcut": { "message": "無效的捷徑" @@ -4307,7 +4377,7 @@ "message": "取消封存" }, "archived": { - "message": "Archived" + "message": "已封存" }, "itemsInArchive": { "message": "封存中的項目" @@ -4331,19 +4401,19 @@ "message": "封存的項目將不會出現在一般搜尋結果或自動填入建議中。確定要封存此項目嗎?" }, "unArchiveAndSave": { - "message": "Unarchive and save" + "message": "取消封存並儲存" }, "restartPremium": { - "message": "Restart Premium" + "message": "重新啟用進階版" }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "您的進階版訂閱已到期" }, "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": "若要重新存取您的封存項目,請重新啟用進階版訂閱。若您在重新啟用前編輯封存項目的詳細資料,它將會被移回您的密碼庫。" }, "itemRestored": { - "message": "Item has been restored" + "message": "已還原項目" }, "zipPostalCodeLabel": { "message": "郵編 / 郵政代碼" diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4734288f3c1..63b288e9161 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -50,10 +50,10 @@ import { WindowMain } from "./main/window.main"; import { NativeAutofillMain } from "./platform/main/autofill/native-autofill.main"; import { ClipboardMain } from "./platform/main/clipboard.main"; import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener"; +import { ElectronStorageService } from "./platform/main/electron-storage.service"; import { VersionMain } from "./platform/main/version.main"; import { DesktopSettingsService } from "./platform/services/desktop-settings.service"; import { ElectronLogMainService } from "./platform/services/electron-log.main.service"; -import { ElectronStorageService } from "./platform/services/electron-storage.service"; import { EphemeralValueStorageService } from "./platform/services/ephemeral-value-storage.main.service"; import { I18nMainService } from "./platform/services/i18n.main.service"; import { SSOLocalhostCallbackService } from "./platform/services/sso-localhost-callback.service"; diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index bbdd2ad0a0f..b2008d57bcd 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -4,7 +4,7 @@ import { once } from "node:events"; import * as path from "path"; import * as url from "url"; -import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, nativeTheme, screen, session } from "electron"; import { concatMap, firstValueFrom, pairwise } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -122,6 +122,7 @@ export class WindowMain { if (!isMacAppStore()) { const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { + dialog.showErrorBox("Error", "An instance of Bitwarden Desktop is already running."); app.quit(); return; } else { diff --git a/apps/desktop/src/platform/services/electron-storage.service.ts b/apps/desktop/src/platform/main/electron-storage.service.ts similarity index 93% rename from apps/desktop/src/platform/services/electron-storage.service.ts rename to apps/desktop/src/platform/main/electron-storage.service.ts index 34aa8837475..08cc5cfbd40 100644 --- a/apps/desktop/src/platform/services/electron-storage.service.ts +++ b/apps/desktop/src/platform/main/electron-storage.service.ts @@ -3,7 +3,7 @@ import * as fs from "fs"; import { ipcMain } from "electron"; -import ElectronStore from "electron-store"; +import ElectronStore, { Options as ElectronStoreOptions } from "electron-store"; import { Subject } from "rxjs"; import { @@ -35,7 +35,7 @@ export class ElectronStorageService implements AbstractStorageService { NodeUtils.mkdirpSync(dir, "700"); } const fileMode = isWindowsPortable() ? 0o666 : 0o600; - const storeConfig: ElectronStore.Options> = { + const storeConfig: ElectronStoreOptions> = { defaults: defaults, name: "data", configFileMode: fileMode, diff --git a/apps/desktop/src/platform/main/tsconfig.json b/apps/desktop/src/platform/main/tsconfig.json new file mode 100644 index 00000000000..8b49a11b574 --- /dev/null +++ b/apps/desktop/src/platform/main/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.main", + "include": ["./**/*.ts"] +} diff --git a/apps/desktop/src/scss/migration.scss b/apps/desktop/src/scss/migration.scss deleted file mode 100644 index ba70d4fa009..00000000000 --- a/apps/desktop/src/scss/migration.scss +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Desktop UI Migration - * - * These are temporary styles during the desktop ui migration. - **/ - -/** - * This removes any padding applied by the bit-layout to content. - * This should be revisited once the table is migrated, and again once drawers are migrated. - **/ -bit-layout { - #main-content { - padding: 0 0 0 0; - } -} -/** - * Send list panel styling for send-v2 component - * Temporary during migration - width handled by tw-w-2/5 - **/ -.vault > .send-items-panel { - order: 2; - min-width: 200px; - border-right: 1px solid; - - @include themify($themes) { - background-color: themed("backgroundColor"); - border-right-color: themed("borderColor"); - } -} diff --git a/apps/desktop/src/scss/styles.scss b/apps/desktop/src/scss/styles.scss index b4082afd38c..c579e6acdc0 100644 --- a/apps/desktop/src/scss/styles.scss +++ b/apps/desktop/src/scss/styles.scss @@ -15,6 +15,5 @@ @import "left-nav.scss"; @import "loading.scss"; @import "plugins.scss"; -@import "migration.scss"; @import "../../../../libs/angular/src/scss/icons.scss"; @import "../../../../libs/components/src/multi-select/scss/bw.theme"; diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/collection-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/collection-filter.component.html new file mode 100644 index 00000000000..2ee78adcfb0 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/collection-filter.component.html @@ -0,0 +1,24 @@ +@if (collection().children.length) { + + @for (childCollection of collection().children; track childCollection.node.id) { + + } + +} @else { + +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/collection-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/collection-filter.component.ts new file mode 100644 index 00000000000..6a801e78ec2 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/collection-filter.component.ts @@ -0,0 +1,38 @@ +import { Component, input, computed } from "@angular/core"; + +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { NavigationModule, A11yTitleDirective } from "@bitwarden/components"; +import { VaultFilter, CollectionFilter } from "@bitwarden/vault"; + +// 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-collection-filter", + templateUrl: "collection-filter.component.html", + imports: [A11yTitleDirective, NavigationModule], +}) +export class CollectionFilterComponent { + protected readonly collection = input.required>(); + protected readonly activeFilter = input(); + + protected readonly displayName = computed(() => { + return this.collection().node.name; + }); + + protected readonly isActive = computed(() => { + return ( + this.collection().node.id === this.activeFilter()?.collectionId && + !!this.activeFilter()?.selectedCollectionNode + ); + }); + + protected applyFilter(event: Event) { + event.stopPropagation(); + + const filter = this.activeFilter(); + + if (filter) { + filter.selectedCollectionNode = this.collection(); + } + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.html new file mode 100644 index 00000000000..f063167c48f --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.html @@ -0,0 +1,50 @@ +@if (folder().children.length) { + + @if (folder()?.node.id) { + + } + @for (childFolder of folder().children; track childFolder.node.id) { + + } + +} @else { + + @if (folder()?.node.id) { + + } + +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.ts new file mode 100644 index 00000000000..dd2d5b504c8 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.ts @@ -0,0 +1,43 @@ +import { Component, input, computed, output } from "@angular/core"; + +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { IconButtonModule, NavigationModule, A11yTitleDirective } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { VaultFilter, FolderFilter } from "@bitwarden/vault"; + +// 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-folder-filter", + templateUrl: "folder-filter.component.html", + imports: [A11yTitleDirective, NavigationModule, IconButtonModule, I18nPipe], +}) +export class FolderFilterComponent { + protected readonly folder = input.required>(); + protected readonly activeFilter = input(); + protected onEditFolder = output(); + + protected readonly displayName = computed(() => { + return this.folder().node.name; + }); + + protected readonly isActive = computed(() => { + return ( + this.folder().node.id === this.activeFilter()?.folderId && + !!this.activeFilter()?.selectedFolderNode + ); + }); + + protected applyFilter(event: Event) { + event.stopPropagation(); + const filter = this.activeFilter(); + + if (filter) { + filter.selectedFolderNode = this.folder(); + } + } + + protected editFolder(folder: FolderFilter) { + this.onEditFolder.emit(folder); + } +} 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 new file mode 100644 index 00000000000..e4e11b82a8a --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.html @@ -0,0 +1,32 @@ +@if (show()) { + + @for (organization of organizations().children ?? []; track organization.node.id) { + + @if (!organization.node.enabled) { + + + + } + } + +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.ts new file mode 100644 index 00000000000..520c29833e3 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.ts @@ -0,0 +1,82 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Component, computed, input, inject } from "@angular/core"; + +import { DisplayMode } from "@bitwarden/angular/vault/vault-filter/models/display-mode"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { ToastService, NavigationModule, A11yTitleDirective } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { OrganizationFilter, VaultFilter, VaultFilterServiceAbstraction } from "@bitwarden/vault"; + +// 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-organization-filter", + templateUrl: "organization-filter.component.html", + imports: [A11yTitleDirective, NavigationModule, I18nPipe], +}) +export class OrganizationFilterComponent { + private toastService: ToastService = inject(ToastService); + private i18nService: I18nService = inject(I18nService); + private vaultFilterService: VaultFilterServiceAbstraction = inject(VaultFilterServiceAbstraction); + + protected readonly hide = input(false); + protected readonly organizations = input.required>(); + protected readonly activeFilter = input(); + protected readonly activeOrganizationDataOwnership = input(false); + protected readonly activeSingleOrganizationPolicy = input(false); + + protected readonly show = computed(() => { + const hiddenDisplayModes: DisplayMode[] = [ + "singleOrganizationAndOrganizatonDataOwnershipPolicies", + ]; + return ( + !this.hide() && + this.organizations()?.children.length > 0 && + hiddenDisplayModes.indexOf(this.displayMode()) === -1 + ); + }); + + protected readonly displayMode = computed(() => { + let displayMode: DisplayMode = "organizationMember"; + if (this.organizations() == null || this.organizations().children.length < 1) { + displayMode = "noOrganizations"; + } else if (this.activeOrganizationDataOwnership() && !this.activeSingleOrganizationPolicy()) { + displayMode = "organizationDataOwnershipPolicy"; + } else if (!this.activeOrganizationDataOwnership() && this.activeSingleOrganizationPolicy()) { + displayMode = "singleOrganizationPolicy"; + } else if (this.activeOrganizationDataOwnership() && this.activeSingleOrganizationPolicy()) { + displayMode = "singleOrganizationAndOrganizatonDataOwnershipPolicies"; + } + + return displayMode; + }); + + protected applyFilter(event: Event, organization: TreeNode) { + event.stopPropagation(); + if (!organization.node.enabled) { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("disabledOrganizationFilterError"), + }); + return; + } + + this.vaultFilterService.setOrganizationFilter(organization.node); + const filter = this.activeFilter(); + + if (filter) { + filter.selectedOrganizationNode = organization; + } + } + + protected applyAllVaultsFilter() { + this.vaultFilterService.clearOrganizationFilter(); + const filter = this.activeFilter(); + + if (filter) { + filter.selectedOrganizationNode = null; + } + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.html new file mode 100644 index 00000000000..b6b22a3c68d --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.html @@ -0,0 +1,24 @@ +@if (!hideArchive()) { + + @if (!(canArchive$ | async)) { + + + + } + +} + diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.ts new file mode 100644 index 00000000000..f3e23cd04e5 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/status-filter.component.ts @@ -0,0 +1,80 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { CommonModule } from "@angular/common"; +import { Component, viewChild, input, inject } from "@angular/core"; +import { combineLatest, firstValueFrom, map, switchMap } from "rxjs"; + +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { NavigationModule, A11yTitleDirective } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { VaultFilter, CipherStatus, CipherTypeFilter } from "@bitwarden/vault"; + +// 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-status-filter", + templateUrl: "status-filter.component.html", + imports: [CommonModule, A11yTitleDirective, NavigationModule, PremiumBadgeComponent, I18nPipe], +}) +export class StatusFilterComponent { + private accountService: AccountService = inject(AccountService); + private cipherArchiveService: CipherArchiveService = inject(CipherArchiveService); + + protected readonly hideArchive = input(false); + protected readonly activeFilter = input.required(); + + private readonly premiumBadgeComponent = viewChild(PremiumBadgeComponent); + + protected readonly archiveFilter: CipherTypeFilter = { + id: "archive", + name: "archiveNoun", + type: "archive", + icon: "bwi-archive", + }; + protected readonly trashFilter: CipherTypeFilter = { + id: "trash", + name: "trash", + type: "trash", + icon: "bwi-trash", + }; + + protected applyFilter(filterType: CipherStatus) { + let filter: CipherTypeFilter | null = null; + if (filterType === "archive") { + filter = this.archiveFilter; + } else if (filterType === "trash") { + filter = this.trashFilter; + } + + if (filter) { + this.activeFilter().selectedCipherTypeNode = new TreeNode(filter, null); + } + } + + private userId$ = this.accountService.activeAccount$.pipe(getUserId); + protected canArchive$ = this.userId$.pipe( + switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)), + ); + + protected hasArchivedCiphers$ = this.userId$.pipe( + switchMap((userId) => + this.cipherArchiveService.archivedCiphers$(userId).pipe(map((ciphers) => ciphers.length > 0)), + ), + ); + + protected async handleArchiveFilter(event: Event) { + const [canArchive, hasArchivedCiphers] = await firstValueFrom( + combineLatest([this.canArchive$, this.hasArchivedCiphers$]), + ); + + if (canArchive || hasArchivedCiphers) { + this.applyFilter("archive"); + } else { + await this.premiumBadgeComponent()?.promptForPremium(event); + } + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/type-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/type-filter.component.html new file mode 100644 index 00000000000..c9807e62066 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/type-filter.component.html @@ -0,0 +1,26 @@ + + @for (typeFilter of typeFilters$ | async; track typeFilter) { + + } + diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/type-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/type-filter.component.ts new file mode 100644 index 00000000000..e5e7a7691e4 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/type-filter.component.ts @@ -0,0 +1,57 @@ +import { CommonModule } from "@angular/common"; +import { Component, input, inject } from "@angular/core"; +import { map, shareReplay } from "rxjs"; + +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { NavigationModule, A11yTitleDirective } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { VaultFilter, CipherTypeFilter } from "@bitwarden/vault"; + +// 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-type-filter", + templateUrl: "type-filter.component.html", + imports: [CommonModule, A11yTitleDirective, NavigationModule, I18nPipe], +}) +export class TypeFilterComponent { + private restrictedItemTypesService: RestrictedItemTypesService = inject( + RestrictedItemTypesService, + ); + + protected readonly cipherTypes = input.required>(); + protected readonly activeFilter = input(); + + protected applyTypeFilter(event: Event, cipherType: TreeNode) { + event.stopPropagation(); + const filter = this.activeFilter(); + + if (filter) { + filter.selectedCipherTypeNode = cipherType; + } + } + + protected applyAllItemsFilter(event: Event) { + const filter = this.activeFilter(); + + if (filter) { + filter.selectedCipherTypeNode = this.cipherTypes(); + } + } + + protected typeFilters$ = this.restrictedItemTypesService.restricted$.pipe( + map((restrictedItemTypes) => + // Filter out restricted item types from the typeFilters array + this.cipherTypes().children.filter( + (type) => + !restrictedItemTypes.some( + (restrictedType) => + restrictedType.allowViewOrgIds.length === 0 && + restrictedType.cipherType === type.node.type, + ), + ), + ), + shareReplay({ bufferSize: 1, refCount: true }), + ); +} 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 new file mode 100644 index 00000000000..e0ae4687ed8 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.html @@ -0,0 +1,44 @@ +@if (!isLoaded) { +
+ +
+} @else { + + + + + @if (showCollectionsFilter()) { + + @for (collection of (collections$ | async)?.children ?? []; track collection.node.id) { + + } + + } + + @for (folder of (folders$ | async)?.children ?? []; track folder.node.id) { + + } + + +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.ts new file mode 100644 index 00000000000..a858e40bf5e --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.ts @@ -0,0 +1,145 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { CommonModule } from "@angular/common"; +import { Component, inject, OnInit, output, computed, signal } from "@angular/core"; +import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +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 { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { NavigationModule, DialogService, A11yTitleDirective } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { + OrganizationFilter, + CipherTypeFilter, + CollectionFilter, + FolderFilter, + VaultFilter, + VaultFilterServiceAbstraction as VaultFilterService, + AddEditFolderDialogComponent, + RoutedVaultFilterBridgeService, +} from "@bitwarden/vault"; + +import { DesktopPremiumUpgradePromptService } from "../../../../services/desktop-premium-upgrade-prompt.service"; + +import { CollectionFilterComponent } from "./filters/collection-filter.component"; +import { FolderFilterComponent } from "./filters/folder-filter.component"; +import { OrganizationFilterComponent } from "./filters/organization-filter.component"; +import { StatusFilterComponent } from "./filters/status-filter.component"; +import { TypeFilterComponent } from "./filters/type-filter.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-vault-filter", + templateUrl: "vault-filter.component.html", + imports: [ + I18nPipe, + NavigationModule, + CommonModule, + OrganizationFilterComponent, + StatusFilterComponent, + TypeFilterComponent, + CollectionFilterComponent, + FolderFilterComponent, + A11yTitleDirective, + ], + providers: [ + { + provide: PremiumUpgradePromptService, + useClass: DesktopPremiumUpgradePromptService, + }, + ], +}) +export class VaultFilterComponent implements OnInit { + private routedVaultFilterBridgeService = inject(RoutedVaultFilterBridgeService); + private vaultFilterService: VaultFilterService = inject(VaultFilterService); + private accountService: AccountService = inject(AccountService); + private cipherArchiveService: CipherArchiveService = inject(CipherArchiveService); + private folderService: FolderService = inject(FolderService); + private policyService: PolicyService = inject(PolicyService); + private dialogService: DialogService = inject(DialogService); + private componentIsDestroyed$ = new Subject(); + + protected readonly activeFilter = signal(null); + protected onFilterChange = output(); + + private activeUserId: UserId; + protected isLoaded = false; + protected showArchiveVaultFilter = false; + protected activeOrganizationDataOwnershipPolicy: boolean; + protected activeSingleOrganizationPolicy: boolean; + protected organizations$: Observable>; + protected collections$: Observable>; + protected folders$: Observable>; + protected cipherTypes$: Observable>; + + protected readonly showCollectionsFilter = computed(() => { + return this.organizations$ != null && !this.activeFilter()?.isMyVaultSelected; + }); + + private async setActivePolicies() { + this.activeOrganizationDataOwnershipPolicy = await firstValueFrom( + this.policyService.policyAppliesToUser$( + PolicyType.OrganizationDataOwnership, + this.activeUserId, + ), + ); + this.activeSingleOrganizationPolicy = await firstValueFrom( + this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, this.activeUserId), + ); + } + + 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 + ) { + 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$, + ); + + this.routedVaultFilterBridgeService.activeFilter$ + .pipe(takeUntil(this.componentIsDestroyed$)) + .subscribe((filter) => { + this.activeFilter.set(filter); + }); + + this.isLoaded = true; + } + + protected async editFolder(folder: FolderFilter) { + if (!this.activeUserId) { + return; + } + const folderView = await firstValueFrom( + this.folderService.getDecrypted$(folder.id, this.activeUserId), + ); + + if (!folderView) { + return; + } + + AddEditFolderDialogComponent.open(this.dialogService, { + editFolderConfig: { + folder: { + ...folderView, + }, + }, + }); + } +} 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 a16ef93e230..c104f76ff2d 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -1,34 +1,34 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { - ChangeDetectorRef, - Component, - NgZone, - OnDestroy, - OnInit, - ViewChild, - ViewContainerRef, -} from "@angular/core"; +import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom, Subject, takeUntil, switchMap, lastValueFrom, Observable } from "rxjs"; +import { + firstValueFrom, + Subject, + takeUntil, + switchMap, + lastValueFrom, + Observable, + from, +} from "rxjs"; import { filter, map, take } from "rxjs/operators"; -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; -import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -60,8 +60,6 @@ import { } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { - AddEditFolderDialogComponent, - AddEditFolderDialogResult, AttachmentDialogResult, AttachmentsV2Component, ChangeLoginPasswordService, @@ -78,6 +76,9 @@ import { PasswordRepromptService, CipherFormComponent, ArchiveCipherUtilitiesService, + VaultFilter, + VaultFilterServiceAbstraction as VaultFilterService, + RoutedVaultFilterBridgeService, } from "@bitwarden/vault"; import { SearchBarService } from "../../../app/layout/search/search-bar.service"; @@ -86,8 +87,6 @@ import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-pr import { invokeMenu, RendererMenuItem } from "../../../utils"; import { AssignCollectionsDesktopComponent } from "../vault/assign-collections"; import { ItemFooterComponent } from "../vault/item-footer.component"; -import { VaultFilterComponent } from "../vault/vault-filter/vault-filter.component"; -import { VaultFilterModule } from "../vault/vault-filter/vault-filter.module"; import { VaultItemsV2Component } from "../vault/vault-items-v2.component"; const BroadcasterSubscriptionId = "VaultComponent"; @@ -107,7 +106,6 @@ const BroadcasterSubscriptionId = "VaultComponent"; ItemModule, ButtonModule, PremiumBadgeComponent, - VaultFilterModule, VaultItemsV2Component, ], providers: [ @@ -134,21 +132,11 @@ const BroadcasterSubscriptionId = "VaultComponent"; }, ], }) -export class VaultComponent - implements OnInit, OnDestroy, CopyClickListener -{ +export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(VaultItemsV2Component, { static: true }) - vaultItemsComponent: VaultItemsV2Component | null = null; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild(VaultFilterComponent, { static: true }) - vaultFilterComponent: VaultFilterComponent | null = null; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) - folderAddEditModalRef: ViewContainerRef | null = null; + vaultItemsComponent: VaultItemsV2Component | null = null; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CipherFormComponent) @@ -194,6 +182,7 @@ export class VaultComponent private componentIsDestroyed$ = new Subject(); private allOrganizations: Organization[] = []; private allCollections: CollectionView[] = []; + private filteredCollections: CollectionView[] = []; constructor( private route: ActivatedRoute, @@ -209,7 +198,6 @@ export class VaultComponent private totpService: TotpService, private passwordRepromptService: PasswordRepromptService, private searchBarService: SearchBarService, - private apiService: ApiService, private dialogService: DialogService, private billingAccountProfileStateService: BillingAccountProfileStateService, private toastService: ToastService, @@ -220,11 +208,12 @@ export class VaultComponent private collectionService: CollectionService, private organizationService: OrganizationService, private folderService: FolderService, - private configService: ConfigService, private authRequestService: AuthRequestServiceAbstraction, private cipherArchiveService: CipherArchiveService, private policyService: PolicyService, private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, + private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService, + private vaultFilterService: VaultFilterService, ) {} async ngOnInit() { @@ -240,6 +229,14 @@ export class VaultComponent this.userHasPremiumAccess = canAccessPremium; }); + // Subscribe to filter changes from router params via the bridge service + this.routedVaultFilterBridgeService.activeFilter$ + .pipe( + switchMap((vaultFilter: VaultFilter) => from(this.applyVaultFilter(vaultFilter))), + takeUntil(this.componentIsDestroyed$), + ) + .subscribe(); + this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { this.ngZone .run(async () => { @@ -267,15 +264,7 @@ export class VaultComponent break; case "syncCompleted": if (this.vaultItemsComponent) { - await this.vaultItemsComponent - .reload(this.activeFilter.buildFilter()) - .catch(() => {}); - } - if (this.vaultFilterComponent) { - await this.vaultFilterComponent - .reloadCollectionsAndFolders(this.activeFilter) - .catch(() => {}); - await this.vaultFilterComponent.reloadOrganizations().catch(() => {}); + await this.vaultItemsComponent.refresh().catch(() => {}); } break; case "modalShown": @@ -377,6 +366,12 @@ export class VaultComponent .subscribe((collections) => { this.allCollections = collections; }); + + this.vaultFilterService.filteredCollections$ + .pipe(takeUntil(this.componentIsDestroyed$)) + .subscribe((collections) => { + this.filteredCollections = collections; + }); } ngOnDestroy() { @@ -403,19 +398,6 @@ export class VaultComponent this.addType = paramCipherAddType; await this.addCipher(this.addType).catch(() => {}); } - - const paramCipherType = toCipherType(params.type); - this.activeFilter = new VaultFilter({ - status: params.deleted ? "trash" : params.favorites ? "favorites" : "all", - cipherType: params.action === "add" || paramCipherType == null ? undefined : paramCipherType, - selectedFolderId: params.folderId, - selectedCollectionId: params.selectedCollectionId, - selectedOrganizationId: params.selectedOrganizationId, - myVaultOnly: params.myVaultOnly ?? false, - }); - if (this.vaultItemsComponent) { - await this.vaultItemsComponent.reload(this.activeFilter.buildFilter()).catch(() => {}); - } } /** @@ -439,9 +421,7 @@ export class VaultComponent this.cipherId = cipher.id; this.cipher = cipher; this.collections = - this.vaultFilterComponent?.collections?.fullList.filter((c) => - cipher.collectionIds.includes(c.id), - ) ?? null; + this.filteredCollections?.filter((c) => cipher.collectionIds.includes(c.id)) ?? null; this.action = "view"; await this.go().catch(() => {}); @@ -798,19 +778,45 @@ export class VaultComponent await this.go().catch(() => {}); } + /** + * Wraps a filter function to handle CipherListView objects. + * CipherListView has a different type structure where type can be a string or object. + * This wrapper converts it to CipherView-compatible structure before filtering. + */ + private wrapFilterForCipherListView( + filterFn: (cipher: CipherView) => boolean, + ): (cipher: CipherViewLike) => boolean { + return (cipher: CipherViewLike) => { + // For CipherListView, create a proxy object with the correct type property + if (CipherViewLikeUtils.isCipherListView(cipher)) { + const proxyCipher = { + ...cipher, + type: CipherViewLikeUtils.getType(cipher), + // Normalize undefined organizationId to null for filter compatibility + organizationId: cipher.organizationId ?? null, + // Explicitly include isDeleted and isArchived since they might be getters + isDeleted: CipherViewLikeUtils.isDeleted(cipher), + isArchived: CipherViewLikeUtils.isArchived(cipher), + }; + return filterFn(proxyCipher as any); + } + }; + } + async applyVaultFilter(vaultFilter: VaultFilter) { this.searchBarService.setPlaceholderText( this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)), ); this.activeFilter = vaultFilter; - await this.vaultItemsComponent - ?.reload( - this.activeFilter.buildFilter(), - vaultFilter.status === "trash", - vaultFilter.status === "archive", - ) - .catch(() => {}); - await this.go().catch(() => {}); + + const originalFilterFn = this.activeFilter.buildFilter(); + const wrappedFilterFn = this.wrapFilterForCipherListView(originalFilterFn); + + await this.vaultItemsComponent?.reload( + wrappedFilterFn, + vaultFilter.isDeleted, + vaultFilter.isArchived, + ); } private getAvailableCollections(cipher: CipherView): CollectionView[] { @@ -824,25 +830,25 @@ export class VaultComponent } private calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string { - if (vaultFilter.status === "favorites") { + if (vaultFilter.isFavorites) { return "searchFavorites"; } - if (vaultFilter.status === "trash") { + if (vaultFilter.isDeleted) { return "searchTrash"; } if (vaultFilter.cipherType != null) { return "searchType"; } - if (vaultFilter.selectedFolderId != null && vaultFilter.selectedFolderId !== "none") { + if (vaultFilter.folderId != null && vaultFilter.folderId !== "none") { return "searchFolder"; } - if (vaultFilter.selectedCollectionId != null) { + if (vaultFilter.collectionId != null) { return "searchCollection"; } - if (vaultFilter.selectedOrganizationId != null) { + if (vaultFilter.organizationId != null) { return "searchOrganization"; } - if (vaultFilter.myVaultOnly) { + if (vaultFilter.isMyVaultSelected) { return "searchMyVault"; } return "searchVault"; @@ -863,23 +869,6 @@ export class VaultComponent if (!folderView) { return; } - - const dialogRef = AddEditFolderDialogComponent.open(this.dialogService, { - editFolderConfig: { - folder: { - ...folderView, - }, - }, - }); - - const result = await lastValueFrom(dialogRef.closed); - - if ( - result === AddEditFolderDialogResult.Deleted || - result === AddEditFolderDialogResult.Created - ) { - await this.vaultFilterComponent?.reloadCollectionsAndFolders(this.activeFilter); - } } /** Refresh the current cipher object */ @@ -919,19 +908,13 @@ export class VaultComponent queryParams = { action: this.action, cipherId: this.cipherId, - favorites: this.favorites ? true : null, - type: this.type, - folderId: this.folderId, - collectionId: this.collectionId, - deleted: this.deleted ? true : null, - organizationId: this.organizationId, - myVaultOnly: this.myVaultOnly, }; } this.router .navigate([], { relativeTo: this.route, queryParams: queryParams, + queryParamsHandling: "merge", replaceUrl: true, }) .catch(() => {}); @@ -966,21 +949,23 @@ export class VaultComponent } private prefillCipherFromFilter() { - if (this.activeFilter.selectedCollectionId != null && this.vaultFilterComponent != null) { - const collections = this.vaultFilterComponent.collections?.fullList.filter( - (c) => c.id === this.activeFilter.selectedCollectionId, + if (this.activeFilter.collectionId != null) { + const collections = this.filteredCollections?.filter( + (c) => c.id === this.activeFilter.collectionId, ); - if (collections.length > 0) { + if (collections?.length > 0) { this.addOrganizationId = collections[0].organizationId; - this.addCollectionIds = [this.activeFilter.selectedCollectionId]; + this.addCollectionIds = [this.activeFilter.collectionId]; } - } else if (this.activeFilter.selectedOrganizationId) { - this.addOrganizationId = this.activeFilter.selectedOrganizationId; + } else if (this.activeFilter.organizationId) { + this.addOrganizationId = this.activeFilter.organizationId; } else { // clear out organizationId when the user switches to a personal vault filter this.addOrganizationId = null; } - this.folderId = this.activeFilter.selectedFolderId; + if (this.activeFilter.folderId && this.activeFilter.selectedFolderNode) { + this.folderId = this.activeFilter.folderId; + } if (this.config == null) { return; diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.html b/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.html index 8b064778444..80beae8407d 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.html +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.html @@ -30,14 +30,14 @@
  • + + +
    + + + {{ "all" | i18n }} + {{ + allCount + }} + + + + {{ "invited" | i18n }} + {{ + invitedCount + }} + + + + {{ "needsConfirmation" | i18n }} + {{ + acceptedUserCount + }} + + + + {{ "revoked" | i18n }} + {{ + revokedCount + }} + + +
    + + + {{ "loading" | i18n }} + + +

    {{ "noMembersInList" | i18n }}

    + + + {{ "usersNeedConfirmed" | i18n }} + + + + + + + + + + + {{ "name" | i18n }} + {{ (organization.useGroups ? "groups" : "collections") | i18n }} + {{ "role" | i18n }} + {{ "policies" | i18n }} + +
    + + +
    + + + + + + + + + + + + + + + +
    + + + + + + + +
    + +
    +
    + + + {{ "invited" | i18n }} + + + {{ "needsConfirmation" | i18n }} + + + {{ "revoked" | i18n }} + +
    +
    + {{ u.email }} +
    +
    +
    + +
    + + +
    + +
    +
    + {{ u.name ?? u.email }} + + {{ "invited" | i18n }} + + + {{ "needsConfirmation" | i18n }} + + + {{ "revoked" | i18n }} + +
    +
    + {{ u.email }} +
    +
    +
    + +
    + + + + + + + + + + + + + + + {{ u.type | userType }} + + + + + {{ u.type | userType }} + + + + + + + {{ "userUsingTwoStep" | i18n }} + + @let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async; + + + {{ "enrolledAccountRecovery" | i18n }} + + + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    +} diff --git a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts new file mode 100644 index 00000000000..99fd81aa48d --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts @@ -0,0 +1,616 @@ +import { Component, computed, Signal } from "@angular/core"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { ActivatedRoute } from "@angular/router"; +import { + combineLatest, + concatMap, + filter, + firstValueFrom, + from, + map, + merge, + Observable, + shareReplay, + switchMap, + take, +} from "rxjs"; + +import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common"; +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { + OrganizationUserStatusType, + OrganizationUserType, + PolicyType, +} from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +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"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { getById } from "@bitwarden/common/platform/misc"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; +import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; + +import { BaseMembersComponent } from "../../common/base-members.component"; +import { + CloudBulkReinviteLimit, + MaxCheckedCount, + PeopleTableDataSource, +} from "../../common/people-table-data-source"; +import { OrganizationUserView } from "../core/views/organization-user.view"; + +import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component"; +import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog"; +import { + MemberDialogManagerService, + MemberExportService, + OrganizationMembersService, +} from "./services"; +import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service"; +import { + MemberActionsService, + MemberActionResult, +} from "./services/member-actions/member-actions.service"; + +class MembersTableDataSource extends PeopleTableDataSource { + protected statusType = OrganizationUserStatusType; +} + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + templateUrl: "deprecated_members.component.html", + standalone: false, +}) +export class MembersComponent extends BaseMembersComponent { + userType = OrganizationUserType; + userStatusType = OrganizationUserStatusType; + memberTab = MemberDialogTab; + protected dataSource: MembersTableDataSource; + + readonly organization: Signal; + status: OrganizationUserStatusType | undefined; + + private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); + + resetPasswordPolicyEnabled$: Observable; + + protected readonly canUseSecretsManager: Signal = computed( + () => this.organization()?.useSecretsManager ?? false, + ); + protected readonly showUserManagementControls: Signal = computed( + () => this.organization()?.canManageUsers ?? false, + ); + protected billingMetadata$: Observable; + + // Fixed sizes used for cdkVirtualScroll + protected rowHeight = 66; + protected rowHeightClass = `tw-h-[66px]`; + + constructor( + apiService: ApiService, + i18nService: I18nService, + organizationManagementPreferencesService: OrganizationManagementPreferencesService, + keyService: KeyService, + validationService: ValidationService, + logService: LogService, + userNamePipe: UserNamePipe, + dialogService: DialogService, + toastService: ToastService, + private route: ActivatedRoute, + protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, + private organizationWarningsService: OrganizationWarningsService, + private memberActionsService: MemberActionsService, + private memberDialogManager: MemberDialogManagerService, + protected billingConstraint: BillingConstraintService, + protected memberService: OrganizationMembersService, + private organizationService: OrganizationService, + private accountService: AccountService, + private policyService: PolicyService, + private policyApiService: PolicyApiServiceAbstraction, + private organizationMetadataService: OrganizationMetadataServiceAbstraction, + private memberExportService: MemberExportService, + private configService: ConfigService, + private environmentService: EnvironmentService, + ) { + super( + apiService, + i18nService, + keyService, + validationService, + logService, + userNamePipe, + dialogService, + organizationManagementPreferencesService, + toastService, + ); + + this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + + const organization$ = this.route.params.pipe( + concatMap((params) => + this.userId$.pipe( + switchMap((userId) => + this.organizationService.organizations$(userId).pipe(getById(params.organizationId)), + ), + filter((organization): organization is Organization => organization != null), + shareReplay({ refCount: true, bufferSize: 1 }), + ), + ), + ); + + this.organization = toSignal(organization$); + + const policies$ = combineLatest([this.userId$, organization$]).pipe( + switchMap(([userId, organization]) => + organization.isProviderUser + ? from(this.policyApiService.getPolicies(organization.id)).pipe( + map((response) => Policy.fromListResponse(response)), + ) + : this.policyService.policies$(userId), + ), + ); + + this.resetPasswordPolicyEnabled$ = combineLatest([organization$, policies$]).pipe( + map( + ([organization, policies]) => + policies + .filter((policy) => policy.type === PolicyType.ResetPassword) + .find((p) => p.organizationId === organization.id)?.enabled ?? false, + ), + ); + + combineLatest([this.route.queryParams, organization$]) + .pipe( + concatMap(async ([qParams, organization]) => { + await this.load(organization!); + + this.searchControl.setValue(qParams.search); + + if (qParams.viewEvents != null) { + const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents); + if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) { + this.openEventsDialog(user[0], organization!); + } + } + }), + takeUntilDestroyed(), + ) + .subscribe(); + + organization$ + .pipe( + switchMap((organization) => + merge( + this.organizationWarningsService.showInactiveSubscriptionDialog$(organization), + this.organizationWarningsService.showSubscribeBeforeFreeTrialEndsDialog$(organization), + ), + ), + takeUntilDestroyed(), + ) + .subscribe(); + + this.billingMetadata$ = organization$.pipe( + switchMap((organization) => + this.organizationMetadataService.getOrganizationMetadata$(organization.id), + ), + shareReplay({ bufferSize: 1, refCount: false }), + ); + + // Stripe is slow, so kick this off in the background but without blocking page load. + // Anyone who needs it will still await the first emission. + this.billingMetadata$.pipe(take(1), takeUntilDestroyed()).subscribe(); + } + + override async load(organization: Organization) { + await super.load(organization); + } + + async getUsers(organization: Organization): Promise { + return await this.memberService.loadUsers(organization); + } + + async removeUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.removeUser(organization, id); + } + + async revokeUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.revokeUser(organization, id); + } + + async restoreUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.restoreUser(organization, id); + } + + async reinviteUser(id: string, organization: Organization): Promise { + return await this.memberActionsService.reinviteUser(organization, id); + } + + async confirmUser( + user: OrganizationUserView, + publicKey: Uint8Array, + organization: Organization, + ): Promise { + return await this.memberActionsService.confirmUser(user, publicKey, organization); + } + + async revoke(user: OrganizationUserView, organization: Organization) { + const confirmed = await this.revokeUserConfirmationDialog(user); + + if (!confirmed) { + return false; + } + + this.actionPromise = this.revokeUser(user.id, organization); + try { + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)), + }); + await this.load(organization); + } else { + throw new Error(result.error); + } + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = undefined; + } + + async restore(user: OrganizationUserView, organization: Organization) { + this.actionPromise = this.restoreUser(user.id, organization); + try { + const result = await this.actionPromise; + if (result.success) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)), + }); + await this.load(organization); + } else { + throw new Error(result.error); + } + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = undefined; + } + + allowResetPassword( + orgUser: OrganizationUserView, + organization: Organization, + orgResetPasswordPolicyEnabled: boolean, + ): boolean { + return this.memberActionsService.allowResetPassword( + orgUser, + organization, + orgResetPasswordPolicyEnabled, + ); + } + + showEnrolledStatus( + orgUser: OrganizationUserUserDetailsResponse, + organization: Organization, + orgResetPasswordPolicyEnabled: boolean, + ): boolean { + return ( + organization.useResetPassword && + orgUser.resetPasswordEnrolled && + orgResetPasswordPolicyEnabled + ); + } + + private async handleInviteDialog(organization: Organization) { + const billingMetadata = await firstValueFrom(this.billingMetadata$); + const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? []; + + const result = await this.memberDialogManager.openInviteDialog( + organization, + billingMetadata, + allUserEmails, + ); + + if (result === MemberDialogResult.Saved) { + await this.load(organization); + } + } + + async invite(organization: Organization) { + const billingMetadata = await firstValueFrom(this.billingMetadata$); + const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata); + if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) { + await this.handleInviteDialog(organization); + this.organizationMetadataService.refreshMetadataCache(); + } + } + + async edit( + user: OrganizationUserView, + organization: Organization, + initialTab: MemberDialogTab = MemberDialogTab.Role, + ) { + const billingMetadata = await firstValueFrom(this.billingMetadata$); + + const result = await this.memberDialogManager.openEditDialog( + user, + organization, + billingMetadata, + initialTab, + ); + + switch (result) { + case MemberDialogResult.Deleted: + this.dataSource.removeUser(user); + break; + case MemberDialogResult.Saved: + case MemberDialogResult.Revoked: + case MemberDialogResult.Restored: + await this.load(organization); + break; + } + } + + async bulkRemove(organization: Organization) { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkRemoveDialog(organization, users); + this.organizationMetadataService.refreshMetadataCache(); + await this.load(organization); + } + + async bulkDelete(organization: Organization) { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkDeleteDialog(organization, users); + await this.load(organization); + } + + async bulkRevoke(organization: Organization) { + await this.bulkRevokeOrRestore(true, organization); + } + + async bulkRestore(organization: Organization) { + await this.bulkRevokeOrRestore(false, organization); + } + + async bulkRevokeOrRestore(isRevoking: boolean, organization: Organization) { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking); + await this.load(organization); + } + + async bulkReinvite(organization: Organization) { + if (this.actionPromise != null) { + return; + } + + let users: OrganizationUserView[]; + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + users = this.dataSource.getCheckedUsersInVisibleOrder(); + } else { + users = this.dataSource.getCheckedUsers(); + } + + const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited); + + // Capture the original count BEFORE enforcing the limit + const originalInvitedCount = allInvitedUsers.length; + + // When feature flag is enabled, limit invited users and uncheck the excess + let filteredUsers: OrganizationUserView[]; + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + filteredUsers = this.dataSource.limitAndUncheckExcess( + allInvitedUsers, + CloudBulkReinviteLimit, + ); + } else { + filteredUsers = allInvitedUsers; + } + + if (filteredUsers.length <= 0) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("noSelectedUsersApplicable"), + }); + return; + } + + try { + const result = await this.memberActionsService.bulkReinvite( + organization, + filteredUsers.map((user) => user.id as UserId), + ); + + if (!result.successful) { + throw new Error(); + } + + // When feature flag is enabled, show toast instead of dialog + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + const selectedCount = originalInvitedCount; + const invitedCount = filteredUsers.length; + + if (selectedCount > CloudBulkReinviteLimit) { + const excludedCount = selectedCount - CloudBulkReinviteLimit; + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t( + "bulkReinviteLimitedSuccessToast", + CloudBulkReinviteLimit.toLocaleString(), + selectedCount.toLocaleString(), + excludedCount.toLocaleString(), + ), + }); + } else { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + }); + } + } else { + // Feature flag disabled - show legacy dialog + await this.memberDialogManager.openBulkStatusDialog( + users, + filteredUsers, + Promise.resolve(result.successful), + this.i18nService.t("bulkReinviteMessage"), + ); + } + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = undefined; + } + + async bulkConfirm(organization: Organization) { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkConfirmDialog(organization, users); + await this.load(organization); + } + + async bulkEnableSM(organization: Organization) { + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users); + + this.dataSource.uncheckAllUsers(); + await this.load(organization); + } + + openEventsDialog(user: OrganizationUserView, organization: Organization) { + this.memberDialogManager.openEventsDialog(user, organization); + } + + async resetPassword(user: OrganizationUserView, organization: Organization) { + if (!user || !user.email || !user.id) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("orgUserDetailsNotFound"), + }); + this.logService.error("Org user details not found when attempting account recovery"); + + return; + } + + const result = await this.memberDialogManager.openAccountRecoveryDialog(user, organization); + if (result === AccountRecoveryDialogResultType.Ok) { + await this.load(organization); + } + + return; + } + + protected async removeUserConfirmationDialog(user: OrganizationUserView) { + return await this.memberDialogManager.openRemoveUserConfirmationDialog(user); + } + + protected async revokeUserConfirmationDialog(user: OrganizationUserView) { + return await this.memberDialogManager.openRevokeUserConfirmationDialog(user); + } + + async deleteUser(user: OrganizationUserView, organization: Organization) { + const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog( + user, + organization, + ); + + if (!confirmed) { + return false; + } + + this.actionPromise = this.memberActionsService.deleteUser(organization, user.id); + try { + const result = await this.actionPromise; + if (!result.success) { + throw new Error(result.error); + } + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)), + }); + this.dataSource.removeUser(user); + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = undefined; + } + + get showBulkRestoreUsers(): boolean { + return this.dataSource + .getCheckedUsers() + .every((member) => member.status == this.userStatusType.Revoked); + } + + get showBulkRevokeUsers(): boolean { + return this.dataSource + .getCheckedUsers() + .every((member) => member.status != this.userStatusType.Revoked); + } + + get showBulkRemoveUsers(): boolean { + return this.dataSource.getCheckedUsers().every((member) => !member.managedByOrganization); + } + + get showBulkDeleteUsers(): boolean { + const validStatuses = [ + this.userStatusType.Accepted, + this.userStatusType.Confirmed, + this.userStatusType.Revoked, + ]; + + return this.dataSource + .getCheckedUsers() + .every((member) => member.managedByOrganization && validStatuses.includes(member.status)); + } + + exportMembers = () => { + const result = this.memberExportService.getMemberExport(this.dataSource.data); + if (result.success) { + this.toastService.showToast({ + variant: "success", + title: undefined, + message: this.i18nService.t("dataExportSuccess"), + }); + } + + if (result.error != null) { + this.validationService.showError(result.error.message); + } + }; +} diff --git a/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts b/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts index 153a2f3a956..2f22b9871b7 100644 --- a/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts @@ -1,23 +1,30 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { canAccessMembersTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FreeBitwardenFamiliesComponent } from "../../../billing/members/free-bitwarden-families.component"; import { organizationPermissionsGuard } from "../guards/org-permissions.guard"; import { canAccessSponsoredFamilies } from "./../../../billing/guards/can-access-sponsored-families.guard"; -import { MembersComponent } from "./members.component"; +import { MembersComponent } from "./deprecated_members.component"; +import { vNextMembersComponent } from "./members.component"; const routes: Routes = [ - { - path: "", - component: MembersComponent, - canActivate: [organizationPermissionsGuard(canAccessMembersTab)], - data: { - titleId: "members", + ...featureFlaggedRoute({ + defaultComponent: MembersComponent, + flaggedComponent: vNextMembersComponent, + featureFlag: FeatureFlag.MembersComponentRefactor, + routeOptions: { + path: "", + canActivate: [organizationPermissionsGuard(canAccessMembersTab)], + data: { + titleId: "members", + }, }, - }, + }), { path: "sponsored-families", component: FreeBitwardenFamiliesComponent, 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 921004e315d..074e9c38f6e 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 @@ -1,5 +1,10 @@ @let organization = this.organization(); -@if (organization) { +@let dataSource = this.dataSource(); +@let bulkActions = bulkMenuOptions$ | async; +@let showConfirmBanner = showConfirmBanner$ | async; +@let isProcessing = this.isProcessing(); + +@if (organization && dataSource) { - + @if (showUserManagementControls()) { + + }
    - - - {{ "all" | i18n }} - {{ - allCount - }} - + @if (showUserManagementControls()) { + + + {{ "all" | i18n }} + @if (dataSource.activeUserCount; as allCount) { + {{ allCount }} + } + - - {{ "invited" | i18n }} - {{ - invitedCount - }} - + + {{ "invited" | i18n }} + @if (dataSource.invitedUserCount; as invitedCount) { + {{ invitedCount }} + } + - - {{ "needsConfirmation" | i18n }} - {{ - acceptedUserCount - }} - + + {{ "needsConfirmation" | i18n }} + @if (dataSource.acceptedUserCount; as acceptedUserCount) { + {{ acceptedUserCount }} + } + - - {{ "revoked" | i18n }} - {{ - revokedCount - }} - - + + {{ "revoked" | i18n }} + @if (dataSource.revokedUserCount; as revokedCount) { + {{ revokedCount }} + } + + + }
    - + @if (!firstLoaded() || !organization || !dataSource) { {{ "loading" | i18n }} - - -

    {{ "noMembersInList" | i18n }}

    - - - {{ "usersNeedConfirmed" | i18n }} - + } @else { + @if (!dataSource.filteredData?.length) { +

    {{ "noMembersInList" | i18n }}

    + } + @if (dataSource.filteredData?.length) { + @if (showConfirmBanner) { + + {{ "usersNeedConfirmed" | i18n }} + + } + - - - - + @if (showUserManagementControls()) { + + + + + } {{ "name" | i18n }} {{ (organization.useGroups ? "groups" : "collections") | i18n }} {{ "role" | i18n }} {{ "policies" | i18n }} - -
    - - -
    + + @if (showUserManagementControls()) { + +
    + + +
    + + } - - - - - - - - - + } + @if (bulkActions.showBulkReinviteUsers) { + + } + @if (bulkActions.showBulkConfirmUsers) { + + } + @if (bulkActions.showBulkRestoreUsers) { + + } + @if (bulkActions.showBulkRevokeUsers) { + + } + @if (bulkActions.showBulkRemoveUsers) { + + } + @if (bulkActions.showBulkDeleteUsers) { + + } @@ -200,10 +221,10 @@ alignContent="middle" [ngClass]="rowHeightClass" > - - - - + @if (showUserManagementControls()) { + + +
    {{ u.name ?? u.email }} - - {{ "invited" | i18n }} - - - {{ "needsConfirmation" | i18n }} - - - {{ "revoked" | i18n }} - -
    -
    - {{ u.email }} + @if (u.status === userStatusType.Invited) { + + {{ "invited" | i18n }} + + } + @if (u.status === userStatusType.Accepted) { + + {{ "needsConfirmation" | i18n }} + + } + @if (u.status === userStatusType.Revoked) { + + {{ "revoked" | i18n }} + + }
    + @if (u.name) { +
    + {{ u.email }} +
    + } -
    - + } @else {
    {{ u.name ?? u.email }} - - {{ "invited" | i18n }} - - - {{ "needsConfirmation" | i18n }} - - - {{ "revoked" | i18n }} - -
    -
    - {{ u.email }} + @if (u.status === userStatusType.Invited) { + + {{ "invited" | i18n }} + + } + @if (u.status === userStatusType.Accepted) { + + {{ "needsConfirmation" | i18n }} + + } + @if (u.status === userStatusType.Revoked) { + + {{ "revoked" | i18n }} + + }
    + @if (u.name) { +
    + {{ u.email }} +
    + }
    -
    + } - + @if (showUserManagementControls()) { - - + } @else { - + } - + @if (showUserManagementControls()) { {{ u.type | userType }} - - + } @else { {{ u.type | userType }} - + } - + @if (u.twoFactorEnabled) { {{ "userUsingTwoStep" | i18n }} - + } @let resetPasswordPolicyEnabled = resetPasswordPolicyEnabled$ | async; - + @if (showEnrolledStatus(u, organization, resetPasswordPolicyEnabled)) { {{ "enrolledAccountRecovery" | i18n }} - + }
    @@ -374,122 +376,131 @@
    - + @if (showUserManagementControls()) { + @if (u.status === userStatusType.Invited) { + + } + @if (u.status === userStatusType.Accepted) { + + } + @if ( + u.status === userStatusType.Accepted || u.status === userStatusType.Invited + ) { + + } - - - + @if (organization.useGroups) { + + } - - - + @if (organization.useEvents && u.status === userStatusType.Confirmed) { + + } + } - + @if (allowResetPassword(u, organization, resetPasswordPolicyEnabled)) { + + } - - - - - - + @if (showUserManagementControls()) { + @if (u.status === userStatusType.Revoked) { + + } + @if (u.status !== userStatusType.Revoked) { + + } + @if (!u.managedByOrganization) { + + } @else { + + } + }
    -
    -
    + } + } } diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts b/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts new file mode 100644 index 00000000000..246c3d8a1c0 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/members.component.spec.ts @@ -0,0 +1,696 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { + OrganizationUserStatusType, + OrganizationUserType, +} from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; +import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; + +import { OrganizationUserView } from "../core/views/organization-user.view"; + +import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component"; +import { MemberDialogResult } from "./components/member-dialog"; +import { vNextMembersComponent } from "./members.component"; +import { + MemberDialogManagerService, + MemberExportService, + OrganizationMembersService, +} from "./services"; +import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service"; +import { + MemberActionsService, + MemberActionResult, +} from "./services/member-actions/member-actions.service"; + +describe("vNextMembersComponent", () => { + let component: vNextMembersComponent; + let fixture: ComponentFixture; + + let mockApiService: MockProxy; + let mockI18nService: MockProxy; + let mockOrganizationManagementPreferencesService: MockProxy; + let mockKeyService: MockProxy; + let mockValidationService: MockProxy; + let mockLogService: MockProxy; + let mockUserNamePipe: MockProxy; + let mockDialogService: MockProxy; + let mockToastService: MockProxy; + let mockActivatedRoute: ActivatedRoute; + let mockDeleteManagedMemberWarningService: MockProxy; + let mockOrganizationWarningsService: MockProxy; + let mockMemberActionsService: MockProxy; + let mockMemberDialogManager: MockProxy; + let mockBillingConstraint: MockProxy; + let mockMemberService: MockProxy; + let mockOrganizationService: MockProxy; + let mockAccountService: FakeAccountService; + let mockPolicyService: MockProxy; + let mockPolicyApiService: MockProxy; + let mockOrganizationMetadataService: MockProxy; + let mockConfigService: MockProxy; + let mockEnvironmentService: MockProxy; + let mockMemberExportService: MockProxy; + let mockFileDownloadService: MockProxy; + + let routeParamsSubject: BehaviorSubject; + let queryParamsSubject: BehaviorSubject; + + const mockUserId = newGuid() as UserId; + const mockOrgId = newGuid() as OrganizationId; + const mockOrg = { + id: mockOrgId, + name: "Test Organization", + enabled: true, + canManageUsers: true, + useSecretsManager: true, + useResetPassword: true, + isProviderUser: false, + } as Organization; + + const mockUser = { + id: newGuid(), + userId: newGuid(), + type: OrganizationUserType.User, + status: OrganizationUserStatusType.Confirmed, + email: "test@example.com", + name: "Test User", + resetPasswordEnrolled: false, + accessSecretsManager: false, + managedByOrganization: false, + twoFactorEnabled: false, + usesKeyConnector: false, + hasMasterPassword: true, + } as OrganizationUserView; + + const mockBillingMetadata = { + isSubscriptionUnpaid: false, + } as Partial; + + beforeEach(async () => { + routeParamsSubject = new BehaviorSubject({ organizationId: mockOrgId }); + queryParamsSubject = new BehaviorSubject({}); + + mockActivatedRoute = { + params: routeParamsSubject.asObservable(), + queryParams: queryParamsSubject.asObservable(), + } as any; + + mockApiService = mock(); + mockI18nService = mock(); + mockI18nService.t.mockImplementation((key: string) => key); + + mockOrganizationManagementPreferencesService = mock(); + mockOrganizationManagementPreferencesService.autoConfirmFingerPrints = { + state$: of(false), + } as any; + + mockKeyService = mock(); + mockValidationService = mock(); + mockLogService = mock(); + mockUserNamePipe = mock(); + mockUserNamePipe.transform.mockReturnValue("Test User"); + + mockDialogService = mock(); + mockToastService = mock(); + mockDeleteManagedMemberWarningService = mock(); + mockOrganizationWarningsService = mock(); + mockMemberActionsService = mock(); + mockMemberDialogManager = mock(); + mockBillingConstraint = mock(); + + mockMemberService = mock(); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + mockOrganizationService = mock(); + mockOrganizationService.organizations$.mockReturnValue(of([mockOrg])); + + mockAccountService = mockAccountServiceWith(mockUserId); + + mockPolicyService = mock(); + + mockPolicyApiService = mock(); + mockOrganizationMetadataService = mock(); + mockOrganizationMetadataService.getOrganizationMetadata$.mockReturnValue( + of(mockBillingMetadata), + ); + + mockConfigService = mock(); + mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); + + mockEnvironmentService = mock(); + mockEnvironmentService.environment$ = of({ + isCloud: () => false, + } as any); + + mockMemberExportService = mock(); + mockFileDownloadService = mock(); + + await TestBed.configureTestingModule({ + declarations: [vNextMembersComponent], + providers: [ + { provide: ApiService, useValue: mockApiService }, + { provide: I18nService, useValue: mockI18nService }, + { + provide: OrganizationManagementPreferencesService, + useValue: mockOrganizationManagementPreferencesService, + }, + { provide: KeyService, useValue: mockKeyService }, + { provide: ValidationService, useValue: mockValidationService }, + { provide: LogService, useValue: mockLogService }, + { provide: UserNamePipe, useValue: mockUserNamePipe }, + { provide: DialogService, useValue: mockDialogService }, + { provide: ToastService, useValue: mockToastService }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { + provide: DeleteManagedMemberWarningService, + useValue: mockDeleteManagedMemberWarningService, + }, + { provide: OrganizationWarningsService, useValue: mockOrganizationWarningsService }, + { provide: MemberActionsService, useValue: mockMemberActionsService }, + { provide: MemberDialogManagerService, useValue: mockMemberDialogManager }, + { provide: BillingConstraintService, useValue: mockBillingConstraint }, + { provide: OrganizationMembersService, useValue: mockMemberService }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: PolicyService, useValue: mockPolicyService }, + { provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService }, + { + provide: OrganizationMetadataServiceAbstraction, + useValue: mockOrganizationMetadataService, + }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + { provide: MemberExportService, useValue: mockMemberExportService }, + { provide: FileDownloadService, useValue: mockFileDownloadService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(vNextMembersComponent, { + remove: { imports: [] }, + add: { template: "
    " }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(vNextMembersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + if (fixture) { + fixture.destroy(); + } + jest.restoreAllMocks(); + }); + + describe("load", () => { + it("should load users and set data source", async () => { + const users = [mockUser]; + mockMemberService.loadUsers.mockResolvedValue(users); + + await component.load(mockOrg); + + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + expect(component["dataSource"]().data).toEqual(users); + expect(component["firstLoaded"]()).toBe(true); + }); + + it("should handle empty response", async () => { + mockMemberService.loadUsers.mockResolvedValue([]); + + await component.load(mockOrg); + + expect(component["dataSource"]().data).toEqual([]); + }); + }); + + describe("remove", () => { + it("should remove user when confirmed", async () => { + mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.removeUser.mockResolvedValue({ success: true }); + + const removeSpy = jest.spyOn(component["dataSource"](), "removeUser"); + + await component.remove(mockUser, mockOrg); + + expect(mockMemberDialogManager.openRemoveUserConfirmationDialog).toHaveBeenCalledWith( + mockUser, + ); + expect(mockMemberActionsService.removeUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(removeSpy).toHaveBeenCalledWith(mockUser); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should not remove user when not confirmed", async () => { + mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(false); + + const result = await component.remove(mockUser, mockOrg); + + expect(result).toBe(false); + expect(mockMemberActionsService.removeUser).not.toHaveBeenCalled(); + }); + + it("should handle errors via handleMemberActionResult", async () => { + mockMemberDialogManager.openRemoveUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.removeUser.mockResolvedValue({ + success: false, + error: "Remove failed", + }); + + await component.remove(mockUser, mockOrg); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "Remove failed", + }); + expect(mockLogService.error).toHaveBeenCalledWith("Remove failed"); + }); + }); + + describe("reinvite", () => { + it("should reinvite user successfully", async () => { + mockMemberActionsService.reinviteUser.mockResolvedValue({ success: true }); + + await component.reinvite(mockUser, mockOrg); + + expect(mockMemberActionsService.reinviteUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should handle errors via handleMemberActionResult", async () => { + mockMemberActionsService.reinviteUser.mockResolvedValue({ + success: false, + error: "Reinvite failed", + }); + + await component.reinvite(mockUser, mockOrg); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "Reinvite failed", + }); + expect(mockLogService.error).toHaveBeenCalledWith("Reinvite failed"); + }); + }); + + describe("confirm", () => { + it("should confirm user with auto-confirm enabled", async () => { + mockOrganizationManagementPreferencesService.autoConfirmFingerPrints.state$ = of(true); + mockMemberActionsService.confirmUser.mockResolvedValue({ success: true }); + + // Mock getPublicKeyForConfirm to return a public key + const mockPublicKey = new Uint8Array([1, 2, 3, 4]); + mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(mockPublicKey); + + const replaceSpy = jest.spyOn(component["dataSource"](), "replaceUser"); + + await component.confirm(mockUser, mockOrg); + + expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalledWith(mockUser); + expect(mockMemberActionsService.confirmUser).toHaveBeenCalledWith( + mockUser, + mockPublicKey, + mockOrg, + ); + expect(replaceSpy).toHaveBeenCalled(); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should handle null user", async () => { + mockOrganizationManagementPreferencesService.autoConfirmFingerPrints.state$ = of(true); + + // Mock getPublicKeyForConfirm to return null + mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(null); + + await component.confirm(mockUser, mockOrg); + + expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalled(); + expect(mockMemberActionsService.confirmUser).not.toHaveBeenCalled(); + expect(mockLogService.warning).toHaveBeenCalledWith("Public key not found"); + }); + + it("should handle API errors gracefully", async () => { + // Mock getPublicKeyForConfirm to return null + mockMemberActionsService.getPublicKeyForConfirm.mockResolvedValue(null); + + await component.confirm(mockUser, mockOrg); + + expect(mockMemberActionsService.getPublicKeyForConfirm).toHaveBeenCalled(); + expect(mockLogService.warning).toHaveBeenCalledWith("Public key not found"); + }); + }); + + describe("revoke", () => { + it("should revoke user when confirmed", async () => { + mockMemberDialogManager.openRevokeUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.revokeUser.mockResolvedValue({ success: true }); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.revoke(mockUser, mockOrg); + + expect(mockMemberDialogManager.openRevokeUserConfirmationDialog).toHaveBeenCalledWith( + mockUser, + ); + expect(mockMemberActionsService.revokeUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should not revoke user when not confirmed", async () => { + mockMemberDialogManager.openRevokeUserConfirmationDialog.mockResolvedValue(false); + + const result = await component.revoke(mockUser, mockOrg); + + expect(result).toBe(false); + expect(mockMemberActionsService.revokeUser).not.toHaveBeenCalled(); + }); + }); + + describe("restore", () => { + it("should restore user successfully", async () => { + mockMemberActionsService.restoreUser.mockResolvedValue({ success: true }); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.restore(mockUser, mockOrg); + + expect(mockMemberActionsService.restoreUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(mockToastService.showToast).toHaveBeenCalled(); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + + it("should handle errors via handleMemberActionResult", async () => { + mockMemberActionsService.restoreUser.mockResolvedValue({ + success: false, + error: "Restore failed", + }); + + await component.restore(mockUser, mockOrg); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "Restore failed", + }); + expect(mockLogService.error).toHaveBeenCalledWith("Restore failed"); + }); + }); + + describe("invite", () => { + it("should open invite dialog when seat limit not reached", async () => { + mockBillingConstraint.seatLimitReached.mockResolvedValue(false); + mockMemberDialogManager.openInviteDialog.mockResolvedValue(MemberDialogResult.Saved); + + await component.invite(mockOrg); + + expect(mockBillingConstraint.checkSeatLimit).toHaveBeenCalledWith( + mockOrg, + mockBillingMetadata, + ); + expect(mockMemberDialogManager.openInviteDialog).toHaveBeenCalledWith( + mockOrg, + mockBillingMetadata, + expect.any(Array), + ); + }); + + it("should reload organization and refresh metadata cache after successful invite", async () => { + mockBillingConstraint.seatLimitReached.mockResolvedValue(false); + mockMemberDialogManager.openInviteDialog.mockResolvedValue(MemberDialogResult.Saved); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.invite(mockOrg); + + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + expect(mockOrganizationMetadataService.refreshMetadataCache).toHaveBeenCalled(); + }); + + it("should not open dialog when seat limit reached", async () => { + mockBillingConstraint.seatLimitReached.mockResolvedValue(true); + + await component.invite(mockOrg); + + expect(mockMemberDialogManager.openInviteDialog).not.toHaveBeenCalled(); + }); + }); + + describe("bulkRemove", () => { + it("should open bulk remove dialog and reload", async () => { + const users = [mockUser]; + jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkRemove(mockOrg); + + expect(mockMemberDialogManager.openBulkRemoveDialog).toHaveBeenCalledWith(mockOrg, users); + expect(mockOrganizationMetadataService.refreshMetadataCache).toHaveBeenCalled(); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("bulkDelete", () => { + it("should open bulk delete dialog and reload", async () => { + const users = [mockUser]; + jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkDelete(mockOrg); + + expect(mockMemberDialogManager.openBulkDeleteDialog).toHaveBeenCalledWith(mockOrg, users); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("bulkRevokeOrRestore", () => { + it.each([ + { isRevoking: true, action: "revoke" }, + { isRevoking: false, action: "restore" }, + ])( + "should open bulk $action dialog and reload when isRevoking is $isRevoking", + async ({ isRevoking }) => { + const users = [mockUser]; + jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkRevokeOrRestore(isRevoking, mockOrg); + + expect(mockMemberDialogManager.openBulkRestoreRevokeDialog).toHaveBeenCalledWith( + mockOrg, + users, + isRevoking, + ); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }, + ); + }); + + describe("bulkReinvite", () => { + it("should reinvite invited users", async () => { + const invitedUser = { + ...mockUser, + status: OrganizationUserStatusType.Invited, + }; + jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false); + jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([invitedUser]); + mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: true }); + + await component.bulkReinvite(mockOrg); + + expect(mockMemberActionsService.bulkReinvite).toHaveBeenCalledWith(mockOrg, [invitedUser.id]); + expect(mockMemberDialogManager.openBulkStatusDialog).toHaveBeenCalled(); + }); + + it("should show error when no invited users selected", async () => { + const confirmedUser = { + ...mockUser, + status: OrganizationUserStatusType.Confirmed, + }; + jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false); + jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([confirmedUser]); + + await component.bulkReinvite(mockOrg); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "errorOccurred", + message: "noSelectedUsersApplicable", + }); + expect(mockMemberActionsService.bulkReinvite).not.toHaveBeenCalled(); + }); + + it("should handle errors", async () => { + const invitedUser = { + ...mockUser, + status: OrganizationUserStatusType.Invited, + }; + jest.spyOn(component["dataSource"](), "isIncreasedBulkLimitEnabled").mockReturnValue(false); + jest.spyOn(component["dataSource"](), "getCheckedUsers").mockReturnValue([invitedUser]); + const error = new Error("Bulk reinvite failed"); + mockMemberActionsService.bulkReinvite.mockResolvedValue({ successful: false, failed: error }); + + await component.bulkReinvite(mockOrg); + + expect(mockValidationService.showError).toHaveBeenCalledWith(error); + }); + }); + + describe("bulkConfirm", () => { + it("should open bulk confirm dialog and reload", async () => { + const users = [mockUser]; + jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkConfirm(mockOrg); + + expect(mockMemberDialogManager.openBulkConfirmDialog).toHaveBeenCalledWith(mockOrg, users); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("bulkEnableSM", () => { + it("should open bulk enable SM dialog and reload", async () => { + const users = [mockUser]; + jest.spyOn(component["dataSource"](), "getCheckedUsersWithLimit").mockReturnValue(users); + jest.spyOn(component["dataSource"](), "uncheckAllUsers"); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.bulkEnableSM(mockOrg); + + expect(mockMemberDialogManager.openBulkEnableSecretsManagerDialog).toHaveBeenCalledWith( + mockOrg, + users, + ); + expect(component["dataSource"]().uncheckAllUsers).toHaveBeenCalled(); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("resetPassword", () => { + it("should open account recovery dialog", async () => { + mockMemberDialogManager.openAccountRecoveryDialog.mockResolvedValue( + AccountRecoveryDialogResultType.Ok, + ); + mockMemberService.loadUsers.mockResolvedValue([mockUser]); + + await component.resetPassword(mockUser, mockOrg); + + expect(mockMemberDialogManager.openAccountRecoveryDialog).toHaveBeenCalledWith( + mockUser, + mockOrg, + ); + expect(mockMemberService.loadUsers).toHaveBeenCalledWith(mockOrg); + }); + }); + + describe("deleteUser", () => { + it("should delete user when confirmed", async () => { + mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.deleteUser.mockResolvedValue({ success: true }); + const removeSpy = jest.spyOn(component["dataSource"](), "removeUser"); + + await component.deleteUser(mockUser, mockOrg); + + expect(mockMemberDialogManager.openDeleteUserConfirmationDialog).toHaveBeenCalledWith( + mockUser, + mockOrg, + ); + expect(mockMemberActionsService.deleteUser).toHaveBeenCalledWith(mockOrg, mockUser.id); + expect(removeSpy).toHaveBeenCalledWith(mockUser); + expect(mockToastService.showToast).toHaveBeenCalled(); + }); + + it("should not delete user when not confirmed", async () => { + mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(false); + + const result = await component.deleteUser(mockUser, mockOrg); + + expect(result).toBe(false); + expect(mockMemberActionsService.deleteUser).not.toHaveBeenCalled(); + }); + + it("should handle errors via handleMemberActionResult", async () => { + mockMemberDialogManager.openDeleteUserConfirmationDialog.mockResolvedValue(true); + mockMemberActionsService.deleteUser.mockResolvedValue({ + success: false, + error: "Delete failed", + }); + + await component.deleteUser(mockUser, mockOrg); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "Delete failed", + }); + expect(mockLogService.error).toHaveBeenCalledWith("Delete failed"); + }); + }); + + describe("handleMemberActionResult", () => { + it("should show success toast when result is successful", async () => { + const result: MemberActionResult = { success: true }; + + await component.handleMemberActionResult(result, "testSuccessKey", mockUser); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "testSuccessKey", + }); + }); + + it("should execute side effect when provided and successful", async () => { + const result: MemberActionResult = { success: true }; + const sideEffect = jest.fn(); + + await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect); + + expect(sideEffect).toHaveBeenCalled(); + }); + + it("should show error toast when result is not successful", async () => { + const result: MemberActionResult = { success: false, error: "Error message" }; + const sideEffect = jest.fn(); + + await component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect); + + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "Error message", + }); + expect(mockLogService.error).toHaveBeenCalledWith("Error message"); + expect(sideEffect).not.toHaveBeenCalled(); + }); + + it("should propagate error when side effect throws", async () => { + const result: MemberActionResult = { success: true }; + const error = new Error("Side effect failed"); + const sideEffect = jest.fn().mockRejectedValue(error); + + await expect( + component.handleMemberActionResult(result, "testSuccessKey", mockUser, sideEffect), + ).rejects.toThrow("Side effect failed"); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index e57cf54c180..9d367657d55 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -1,9 +1,12 @@ -import { Component, computed, Signal } from "@angular/core"; +import { Component, computed, inject, signal, Signal, WritableSignal } from "@angular/core"; import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { FormControl } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; import { + BehaviorSubject, combineLatest, concatMap, + debounceTime, filter, firstValueFrom, from, @@ -15,11 +18,8 @@ import { take, } from "rxjs"; -import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { @@ -35,22 +35,21 @@ import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billin import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { getById } from "@bitwarden/common/platform/misc"; import { DialogService, ToastService } from "@bitwarden/components"; -import { KeyService } from "@bitwarden/key-management"; import { UserId } from "@bitwarden/user-core"; import { BillingConstraintService } from "@bitwarden/web-vault/app/billing/members/billing-constraint/billing-constraint.service"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; -import { BaseMembersComponent } from "../../common/base-members.component"; import { CloudBulkReinviteLimit, MaxCheckedCount, - PeopleTableDataSource, + MembersTableDataSource, + peopleFilter, + showConfirmBanner, } from "../../common/people-table-data-source"; import { OrganizationUserView } from "../core/views/organization-user.view"; @@ -67,8 +66,13 @@ import { MemberActionResult, } from "./services/member-actions/member-actions.service"; -class MembersTableDataSource extends PeopleTableDataSource { - protected statusType = OrganizationUserStatusType; +interface BulkMemberFlags { + showBulkRestoreUsers: boolean; + showBulkRevokeUsers: boolean; + showBulkRemoveUsers: boolean; + showBulkDeleteUsers: boolean; + showBulkConfirmUsers: boolean; + showBulkReinviteUsers: boolean; } // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -77,71 +81,76 @@ class MembersTableDataSource extends PeopleTableDataSource templateUrl: "members.component.html", standalone: false, }) -export class MembersComponent extends BaseMembersComponent { - userType = OrganizationUserType; - userStatusType = OrganizationUserStatusType; - memberTab = MemberDialogTab; - protected dataSource: MembersTableDataSource; - - readonly organization: Signal; - status: OrganizationUserStatusType | undefined; +export class vNextMembersComponent { + protected i18nService = inject(I18nService); + protected validationService = inject(ValidationService); + protected logService = inject(LogService); + protected userNamePipe = inject(UserNamePipe); + protected dialogService = inject(DialogService); + protected toastService = inject(ToastService); + private route = inject(ActivatedRoute); + protected deleteManagedMemberWarningService = inject(DeleteManagedMemberWarningService); + private organizationWarningsService = inject(OrganizationWarningsService); + private memberActionsService = inject(MemberActionsService); + private memberDialogManager = inject(MemberDialogManagerService); + protected billingConstraint = inject(BillingConstraintService); + protected memberService = inject(OrganizationMembersService); + private organizationService = inject(OrganizationService); + private accountService = inject(AccountService); + private policyService = inject(PolicyService); + private policyApiService = inject(PolicyApiServiceAbstraction); + private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); + private configService = inject(ConfigService); + private environmentService = inject(EnvironmentService); + private memberExportService = inject(MemberExportService); private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); - resetPasswordPolicyEnabled$: Observable; + protected userType = OrganizationUserType; + protected userStatusType = OrganizationUserStatusType; + protected memberTab = MemberDialogTab; + + protected searchControl = new FormControl("", { nonNullable: true }); + protected statusToggle = new BehaviorSubject(undefined); + + protected readonly dataSource: Signal = signal( + new MembersTableDataSource(this.configService, this.environmentService), + ); + protected readonly organization: Signal; + protected readonly firstLoaded: WritableSignal = signal(false); + + protected bulkMenuOptions$ = this.dataSource() + .usersUpdated() + .pipe(map((members) => this.bulkMenuOptions(members))); + + protected showConfirmBanner$ = this.dataSource() + .usersUpdated() + .pipe(map(() => showConfirmBanner(this.dataSource()))); + + protected isProcessing = this.memberActionsService.isProcessing; protected readonly canUseSecretsManager: Signal = computed( () => this.organization()?.useSecretsManager ?? false, ); + protected readonly showUserManagementControls: Signal = computed( () => this.organization()?.canManageUsers ?? false, ); + protected billingMetadata$: Observable; + protected resetPasswordPolicyEnabled$: Observable; + // Fixed sizes used for cdkVirtualScroll protected rowHeight = 66; protected rowHeightClass = `tw-h-[66px]`; - constructor( - apiService: ApiService, - i18nService: I18nService, - organizationManagementPreferencesService: OrganizationManagementPreferencesService, - keyService: KeyService, - validationService: ValidationService, - logService: LogService, - userNamePipe: UserNamePipe, - dialogService: DialogService, - toastService: ToastService, - private route: ActivatedRoute, - protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService, - private organizationWarningsService: OrganizationWarningsService, - private memberActionsService: MemberActionsService, - private memberDialogManager: MemberDialogManagerService, - protected billingConstraint: BillingConstraintService, - protected memberService: OrganizationMembersService, - private organizationService: OrganizationService, - private accountService: AccountService, - private policyService: PolicyService, - private policyApiService: PolicyApiServiceAbstraction, - private organizationMetadataService: OrganizationMetadataServiceAbstraction, - private memberExportService: MemberExportService, - private fileDownloadService: FileDownloadService, - private configService: ConfigService, - private environmentService: EnvironmentService, - ) { - super( - apiService, - i18nService, - keyService, - validationService, - logService, - userNamePipe, - dialogService, - organizationManagementPreferencesService, - toastService, - ); - - this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + constructor() { + combineLatest([this.searchControl.valueChanges.pipe(debounceTime(200)), this.statusToggle]) + .pipe(takeUntilDestroyed()) + .subscribe( + ([searchText, status]) => (this.dataSource().filter = peopleFilter(searchText, status)), + ); const organization$ = this.route.params.pipe( concatMap((params) => @@ -184,7 +193,7 @@ export class MembersComponent extends BaseMembersComponent this.searchControl.setValue(qParams.search); if (qParams.viewEvents != null) { - const user = this.dataSource.data.filter((u) => u.id === qParams.viewEvents); + const user = this.dataSource().data.filter((u) => u.id === qParams.viewEvents); if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) { this.openEventsDialog(user[0], organization!); } @@ -218,80 +227,62 @@ export class MembersComponent extends BaseMembersComponent this.billingMetadata$.pipe(take(1), takeUntilDestroyed()).subscribe(); } - override async load(organization: Organization) { - await super.load(organization); + async load(organization: Organization) { + const response = await this.memberService.loadUsers(organization); + this.dataSource().data = response; + this.firstLoaded.set(true); } - async getUsers(organization: Organization): Promise { - return await this.memberService.loadUsers(organization); - } - - async removeUser(id: string, organization: Organization): Promise { - return await this.memberActionsService.removeUser(organization, id); - } - - async revokeUser(id: string, organization: Organization): Promise { - return await this.memberActionsService.revokeUser(organization, id); - } - - async restoreUser(id: string, organization: Organization): Promise { - return await this.memberActionsService.restoreUser(organization, id); - } - - async reinviteUser(id: string, organization: Organization): Promise { - return await this.memberActionsService.reinviteUser(organization, id); - } - - async confirmUser( - user: OrganizationUserView, - publicKey: Uint8Array, - organization: Organization, - ): Promise { - return await this.memberActionsService.confirmUser(user, publicKey, organization); - } - - async revoke(user: OrganizationUserView, organization: Organization) { - const confirmed = await this.revokeUserConfirmationDialog(user); + async remove(user: OrganizationUserView, organization: Organization) { + const confirmed = await this.memberDialogManager.openRemoveUserConfirmationDialog(user); if (!confirmed) { return false; } - this.actionPromise = this.revokeUser(user.id, organization); - try { - const result = await this.actionPromise; - if (result.success) { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("revokedUserId", this.userNamePipe.transform(user)), - }); - await this.load(organization); - } else { - throw new Error(result.error); - } - } catch (e) { - this.validationService.showError(e); + const result = await this.memberActionsService.removeUser(organization, user.id); + const sideEffect = () => this.dataSource().removeUser(user); + await this.handleMemberActionResult(result, "removedUserId", user, sideEffect); + } + + async reinvite(user: OrganizationUserView, organization: Organization) { + const result = await this.memberActionsService.reinviteUser(organization, user.id); + await this.handleMemberActionResult(result, "hasBeenReinvited", user); + } + + async confirm(user: OrganizationUserView, organization: Organization) { + const confirmUserSideEffect = () => { + user.status = this.userStatusType.Confirmed; + this.dataSource().replaceUser(user); + }; + + const publicKeyResult = await this.memberActionsService.getPublicKeyForConfirm(user); + + if (publicKeyResult == null) { + this.logService.warning("Public key not found"); + return; } - this.actionPromise = undefined; + + const result = await this.memberActionsService.confirmUser(user, publicKeyResult, organization); + await this.handleMemberActionResult(result, "hasBeenConfirmed", user, confirmUserSideEffect); + } + + async revoke(user: OrganizationUserView, organization: Organization) { + const confirmed = await this.memberDialogManager.openRevokeUserConfirmationDialog(user); + + if (!confirmed) { + return false; + } + + const result = await this.memberActionsService.revokeUser(organization, user.id); + const sideEffect = async () => await this.load(organization); + await this.handleMemberActionResult(result, "revokedUserId", user, sideEffect); } async restore(user: OrganizationUserView, organization: Organization) { - this.actionPromise = this.restoreUser(user.id, organization); - try { - const result = await this.actionPromise; - if (result.success) { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("restoredUserId", this.userNamePipe.transform(user)), - }); - await this.load(organization); - } else { - throw new Error(result.error); - } - } catch (e) { - this.validationService.showError(e); - } - this.actionPromise = undefined; + const result = await this.memberActionsService.restoreUser(organization, user.id); + const sideEffect = async () => await this.load(organization); + await this.handleMemberActionResult(result, "restoredUserId", user, sideEffect); } allowResetPassword( @@ -307,7 +298,7 @@ export class MembersComponent extends BaseMembersComponent } showEnrolledStatus( - orgUser: OrganizationUserUserDetailsResponse, + orgUser: OrganizationUserView, organization: Organization, orgResetPasswordPolicyEnabled: boolean, ): boolean { @@ -318,9 +309,15 @@ export class MembersComponent extends BaseMembersComponent ); } - private async handleInviteDialog(organization: Organization) { + async invite(organization: Organization) { const billingMetadata = await firstValueFrom(this.billingMetadata$); - const allUserEmails = this.dataSource.data?.map((user) => user.email) ?? []; + const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata); + + if (await this.billingConstraint.seatLimitReached(seatLimitResult, organization)) { + return; + } + + const allUserEmails = this.dataSource().data?.map((user) => user.email) ?? []; const result = await this.memberDialogManager.openInviteDialog( organization, @@ -330,14 +327,6 @@ export class MembersComponent extends BaseMembersComponent if (result === MemberDialogResult.Saved) { await this.load(organization); - } - } - - async invite(organization: Organization) { - const billingMetadata = await firstValueFrom(this.billingMetadata$); - const seatLimitResult = this.billingConstraint.checkSeatLimit(organization, billingMetadata); - if (!(await this.billingConstraint.seatLimitReached(seatLimitResult, organization))) { - await this.handleInviteDialog(organization); this.organizationMetadataService.refreshMetadataCache(); } } @@ -358,7 +347,7 @@ export class MembersComponent extends BaseMembersComponent switch (result) { case MemberDialogResult.Deleted: - this.dataSource.removeUser(user); + this.dataSource().removeUser(user); break; case MemberDialogResult.Saved: case MemberDialogResult.Revoked: @@ -369,57 +358,30 @@ export class MembersComponent extends BaseMembersComponent } async bulkRemove(organization: Organization) { - if (this.actionPromise != null) { - return; - } - - const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); - + const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount); await this.memberDialogManager.openBulkRemoveDialog(organization, users); this.organizationMetadataService.refreshMetadataCache(); await this.load(organization); } async bulkDelete(organization: Organization) { - if (this.actionPromise != null) { - return; - } - - const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); - + const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount); await this.memberDialogManager.openBulkDeleteDialog(organization, users); await this.load(organization); } - async bulkRevoke(organization: Organization) { - await this.bulkRevokeOrRestore(true, organization); - } - - async bulkRestore(organization: Organization) { - await this.bulkRevokeOrRestore(false, organization); - } - async bulkRevokeOrRestore(isRevoking: boolean, organization: Organization) { - if (this.actionPromise != null) { - return; - } - - const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); - + const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount); await this.memberDialogManager.openBulkRestoreRevokeDialog(organization, users, isRevoking); await this.load(organization); } async bulkReinvite(organization: Organization) { - if (this.actionPromise != null) { - return; - } - let users: OrganizationUserView[]; - if (this.dataSource.isIncreasedBulkLimitEnabled()) { - users = this.dataSource.getCheckedUsersInVisibleOrder(); + if (this.dataSource().isIncreasedBulkLimitEnabled()) { + users = this.dataSource().getCheckedUsersInVisibleOrder(); } else { - users = this.dataSource.getCheckedUsers(); + users = this.dataSource().getCheckedUsers(); } const allInvitedUsers = users.filter((u) => u.status === OrganizationUserStatusType.Invited); @@ -429,8 +391,8 @@ export class MembersComponent extends BaseMembersComponent // When feature flag is enabled, limit invited users and uncheck the excess let filteredUsers: OrganizationUserView[]; - if (this.dataSource.isIncreasedBulkLimitEnabled()) { - filteredUsers = this.dataSource.limitAndUncheckExcess( + if (this.dataSource().isIncreasedBulkLimitEnabled()) { + filteredUsers = this.dataSource().limitAndUncheckExcess( allInvitedUsers, CloudBulkReinviteLimit, ); @@ -447,70 +409,59 @@ export class MembersComponent extends BaseMembersComponent return; } - try { - const result = await this.memberActionsService.bulkReinvite( - organization, - filteredUsers.map((user) => user.id as UserId), - ); + const result = await this.memberActionsService.bulkReinvite( + organization, + filteredUsers.map((user) => user.id as UserId), + ); - if (!result.successful) { - throw new Error(); - } - - // When feature flag is enabled, show toast instead of dialog - if (this.dataSource.isIncreasedBulkLimitEnabled()) { - const selectedCount = originalInvitedCount; - const invitedCount = filteredUsers.length; - - if (selectedCount > CloudBulkReinviteLimit) { - const excludedCount = selectedCount - CloudBulkReinviteLimit; - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t( - "bulkReinviteLimitedSuccessToast", - CloudBulkReinviteLimit.toLocaleString(), - selectedCount.toLocaleString(), - excludedCount.toLocaleString(), - ), - }); - } else { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), - }); - } - } else { - // Feature flag disabled - show legacy dialog - await this.memberDialogManager.openBulkStatusDialog( - users, - filteredUsers, - Promise.resolve(result.successful), - this.i18nService.t("bulkReinviteMessage"), - ); - } - } catch (e) { - this.validationService.showError(e); + if (!result.successful) { + this.validationService.showError(result.failed); + } + + // When feature flag is enabled, show toast instead of dialog + if (this.dataSource().isIncreasedBulkLimitEnabled()) { + const selectedCount = originalInvitedCount; + const invitedCount = filteredUsers.length; + + if (selectedCount > CloudBulkReinviteLimit) { + const excludedCount = selectedCount - CloudBulkReinviteLimit; + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t( + "bulkReinviteLimitedSuccessToast", + CloudBulkReinviteLimit.toLocaleString(), + selectedCount.toLocaleString(), + excludedCount.toLocaleString(), + ), + }); + } else { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + }); + } + } else { + // Feature flag disabled - show legacy dialog + await this.memberDialogManager.openBulkStatusDialog( + users, + filteredUsers, + Promise.resolve(result.successful), + this.i18nService.t("bulkReinviteMessage"), + ); } - this.actionPromise = undefined; } async bulkConfirm(organization: Organization) { - if (this.actionPromise != null) { - return; - } - - const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); - + const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount); await this.memberDialogManager.openBulkConfirmDialog(organization, users); await this.load(organization); } async bulkEnableSM(organization: Organization) { - const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); - + const users = this.dataSource().getCheckedUsersWithLimit(MaxCheckedCount); await this.memberDialogManager.openBulkEnableSecretsManagerDialog(organization, users); - this.dataSource.uncheckAllUsers(); + this.dataSource().uncheckAllUsers(); await this.load(organization); } @@ -538,14 +489,6 @@ export class MembersComponent extends BaseMembersComponent return; } - protected async removeUserConfirmationDialog(user: OrganizationUserView) { - return await this.memberDialogManager.openRemoveUserConfirmationDialog(user); - } - - protected async revokeUserConfirmationDialog(user: OrganizationUserView) { - return await this.memberDialogManager.openRevokeUserConfirmationDialog(user); - } - async deleteUser(user: OrganizationUserView, organization: Organization) { const confirmed = await this.memberDialogManager.openDeleteUserConfirmationDialog( user, @@ -556,80 +499,72 @@ export class MembersComponent extends BaseMembersComponent return false; } - this.actionPromise = this.memberActionsService.deleteUser(organization, user.id); - try { - const result = await this.actionPromise; - if (!result.success) { - throw new Error(result.error); - } + const result = await this.memberActionsService.deleteUser(organization, user.id); + await this.handleMemberActionResult(result, "organizationUserDeleted", user, () => { + this.dataSource().removeUser(user); + }); + } + + async handleMemberActionResult( + result: MemberActionResult, + successKey: string, + user: OrganizationUserView, + sideEffect?: () => void | Promise, + ) { + if (result.error != null) { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t(result.error), + }); + this.logService.error(result.error); + return; + } + + if (result.success) { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("organizationUserDeleted", this.userNamePipe.transform(user)), + message: this.i18nService.t(successKey, this.userNamePipe.transform(user)), }); - this.dataSource.removeUser(user); - } catch (e) { - this.validationService.showError(e); + + if (sideEffect) { + await sideEffect(); + } } - this.actionPromise = undefined; } - get showBulkRestoreUsers(): boolean { - return this.dataSource - .getCheckedUsers() - .every((member) => member.status == this.userStatusType.Revoked); - } - - get showBulkRevokeUsers(): boolean { - return this.dataSource - .getCheckedUsers() - .every((member) => member.status != this.userStatusType.Revoked); - } - - get showBulkRemoveUsers(): boolean { - return this.dataSource.getCheckedUsers().every((member) => !member.managedByOrganization); - } - - get showBulkDeleteUsers(): boolean { + private bulkMenuOptions(members: OrganizationUserView[]): BulkMemberFlags { const validStatuses = [ - this.userStatusType.Accepted, - this.userStatusType.Confirmed, - this.userStatusType.Revoked, + OrganizationUserStatusType.Accepted, + OrganizationUserStatusType.Confirmed, + OrganizationUserStatusType.Revoked, ]; - return this.dataSource - .getCheckedUsers() - .every((member) => member.managedByOrganization && validStatuses.includes(member.status)); + const result = { + showBulkConfirmUsers: members.every((m) => m.status == OrganizationUserStatusType.Accepted), + showBulkReinviteUsers: members.every((m) => m.status == OrganizationUserStatusType.Invited), + showBulkRestoreUsers: members.every((m) => m.status == OrganizationUserStatusType.Revoked), + showBulkRevokeUsers: members.every((m) => m.status != OrganizationUserStatusType.Revoked), + showBulkRemoveUsers: members.every((m) => !m.managedByOrganization), + showBulkDeleteUsers: members.every( + (m) => m.managedByOrganization && validStatuses.includes(m.status), + ), + }; + + return result; } - exportMembers = async (): Promise => { - try { - const members = this.dataSource.data; - if (!members || members.length === 0) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("noMembersToExport"), - }); - return; - } - - const csvData = this.memberExportService.getMemberExport(members); - const fileName = this.memberExportService.getFileName("org-members"); - - this.fileDownloadService.download({ - fileName: fileName, - blobData: csvData, - blobOptions: { type: "text/plain" }, - }); - + exportMembers = () => { + const result = this.memberExportService.getMemberExport(this.dataSource().data); + if (result.success) { this.toastService.showToast({ variant: "success", title: undefined, message: this.i18nService.t("dataExportSuccess"), }); - } catch (e) { - this.validationService.showError(e); - this.logService.error(`Failed to export members: ${e}`); + } + + if (result.error != null) { + this.validationService.showError(result.error.message); } }; } diff --git a/apps/web/src/app/admin-console/organizations/members/members.module.ts b/apps/web/src/app/admin-console/organizations/members/members.module.ts index 65625cfd247..9fd477b1e29 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.module.ts @@ -17,8 +17,9 @@ import { BulkRemoveDialogComponent } from "./components/bulk/bulk-remove-dialog. import { BulkRestoreRevokeComponent } from "./components/bulk/bulk-restore-revoke.component"; import { BulkStatusComponent } from "./components/bulk/bulk-status.component"; import { UserDialogModule } from "./components/member-dialog"; +import { MembersComponent } from "./deprecated_members.component"; import { MembersRoutingModule } from "./members-routing.module"; -import { MembersComponent } from "./members.component"; +import { vNextMembersComponent } from "./members.component"; import { UserStatusPipe } from "./pipes"; import { OrganizationMembersService, @@ -46,6 +47,7 @@ import { BulkRestoreRevokeComponent, BulkStatusComponent, MembersComponent, + vNextMembersComponent, BulkDeleteDialogComponent, UserStatusPipe, ], diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index 1dd75a79180..1df285d7ba2 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -1,3 +1,4 @@ +import { TestBed } from "@angular/core/testing"; import { MockProxy, mock } from "jest-mock-extended"; import { of } from "rxjs"; @@ -6,6 +7,9 @@ import { OrganizationUserBulkResponse, OrganizationUserService, } from "@bitwarden/admin-console/common"; +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; import { OrganizationUserType, OrganizationUserStatusType, @@ -14,8 +18,11 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; @@ -56,12 +63,29 @@ describe("MemberActionsService", () => { resetPasswordEnrolled: true, } as OrganizationUserView; - service = new MemberActionsService( - organizationUserApiService, - organizationUserService, - configService, - organizationMetadataService, - ); + TestBed.configureTestingModule({ + providers: [ + MemberActionsService, + { provide: OrganizationUserApiService, useValue: organizationUserApiService }, + { provide: OrganizationUserService, useValue: organizationUserService }, + { provide: ConfigService, useValue: configService }, + { + provide: OrganizationMetadataServiceAbstraction, + useValue: organizationMetadataService, + }, + { provide: ApiService, useValue: mock() }, + { provide: DialogService, useValue: mock() }, + { provide: KeyService, useValue: mock() }, + { provide: LogService, useValue: mock() }, + { + provide: OrganizationManagementPreferencesService, + useValue: mock(), + }, + { provide: UserNamePipe, useValue: mock() }, + ], + }); + + service = TestBed.inject(MemberActionsService); }); describe("inviteUser", () => { @@ -660,4 +684,26 @@ describe("MemberActionsService", () => { expect(result).toBe(false); }); }); + + describe("isProcessing signal", () => { + it("should be false initially", () => { + expect(service.isProcessing()).toBe(false); + }); + + it("should be false after operation completes successfully", async () => { + organizationUserApiService.removeOrganizationUser.mockResolvedValue(undefined); + + await service.removeUser(mockOrganization, userIdToManage); + + expect(service.isProcessing()).toBe(false); + }); + + it("should be false after operation fails", async () => { + organizationUserApiService.removeOrganizationUser.mockRejectedValue(new Error("Failed")); + + await service.removeUser(mockOrganization, userIdToManage); + + expect(service.isProcessing()).toBe(false); + }); + }); }); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index a44bfa4b19c..5833238209c 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -1,23 +1,33 @@ -import { Injectable } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { inject, Injectable, signal } from "@angular/core"; +import { lastValueFrom, firstValueFrom } from "rxjs"; import { OrganizationUserApiService, OrganizationUserBulkResponse, OrganizationUserService, } from "@bitwarden/admin-console/common"; +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; import { OrganizationUserType, OrganizationUserStatusType, } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { DialogService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; import { UserId } from "@bitwarden/user-core"; +import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; +import { UserConfirmComponent } from "../../../manage/user-confirm.component"; export const REQUESTS_PER_BATCH = 500; @@ -33,12 +43,26 @@ export interface BulkActionResult { @Injectable() export class MemberActionsService { - constructor( - private organizationUserApiService: OrganizationUserApiService, - private organizationUserService: OrganizationUserService, - private configService: ConfigService, - private organizationMetadataService: OrganizationMetadataServiceAbstraction, - ) {} + private organizationUserApiService = inject(OrganizationUserApiService); + private organizationUserService = inject(OrganizationUserService); + private configService = inject(ConfigService); + private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); + private apiService = inject(ApiService); + private dialogService = inject(DialogService); + private keyService = inject(KeyService); + private logService = inject(LogService); + private orgManagementPrefs = inject(OrganizationManagementPreferencesService); + private userNamePipe = inject(UserNamePipe); + + readonly isProcessing = signal(false); + + private startProcessing(): void { + this.isProcessing.set(true); + } + + private endProcessing(): void { + this.isProcessing.set(false); + } async inviteUser( organization: Organization, @@ -48,6 +72,7 @@ export class MemberActionsService { collections?: any[], groups?: string[], ): Promise { + this.startProcessing(); try { await this.organizationUserApiService.postOrganizationUserInvite(organization.id, { emails: [email], @@ -60,55 +85,72 @@ export class MemberActionsService { return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } async removeUser(organization: Organization, userId: string): Promise { + this.startProcessing(); try { await this.organizationUserApiService.removeOrganizationUser(organization.id, userId); this.organizationMetadataService.refreshMetadataCache(); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } async revokeUser(organization: Organization, userId: string): Promise { + this.startProcessing(); try { await this.organizationUserApiService.revokeOrganizationUser(organization.id, userId); this.organizationMetadataService.refreshMetadataCache(); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } async restoreUser(organization: Organization, userId: string): Promise { + this.startProcessing(); try { await this.organizationUserApiService.restoreOrganizationUser(organization.id, userId); this.organizationMetadataService.refreshMetadataCache(); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } async deleteUser(organization: Organization, userId: string): Promise { + this.startProcessing(); try { await this.organizationUserApiService.deleteOrganizationUser(organization.id, userId); this.organizationMetadataService.refreshMetadataCache(); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } async reinviteUser(organization: Organization, userId: string): Promise { + this.startProcessing(); try { await this.organizationUserApiService.postOrganizationUserReinvite(organization.id, userId); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } @@ -117,6 +159,7 @@ export class MemberActionsService { publicKey: Uint8Array, organization: Organization, ): Promise { + this.startProcessing(); try { await firstValueFrom( this.organizationUserService.confirmUser(organization, user.id, publicKey), @@ -124,27 +167,32 @@ export class MemberActionsService { return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; + } finally { + this.endProcessing(); } } async bulkReinvite(organization: Organization, userIds: UserId[]): Promise { - const increaseBulkReinviteLimitForCloud = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), - ); - if (increaseBulkReinviteLimitForCloud) { - return await this.vNextBulkReinvite(organization, userIds); - } else { - try { + this.startProcessing(); + try { + const increaseBulkReinviteLimitForCloud = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + ); + if (increaseBulkReinviteLimitForCloud) { + return await this.vNextBulkReinvite(organization, userIds); + } else { const result = await this.organizationUserApiService.postManyOrganizationUserReinvite( organization.id, userIds, ); return { successful: result, failed: [] }; - } catch (error) { - return { - failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), - }; } + } catch (error) { + return { + failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), + }; + } finally { + this.endProcessing(); } } @@ -236,4 +284,50 @@ export class MemberActionsService { failed: allFailed, }; } + + /** + * Shared dialog workflow that returns the public key when the user accepts the selected confirmation + * action. + * + * @param user - The user to confirm (must implement ConfirmableUser interface) + * @param userNamePipe - Pipe to transform user names for display + * @param orgManagementPrefs - Service providing organization management preferences + * @returns Promise containing the pulic key that resolves when the confirm action is accepted + * or undefined when cancelled + */ + async getPublicKeyForConfirm( + user: OrganizationUserView | ProviderUser, + ): Promise { + try { + assertNonNullish(user, "Cannot confirm null user."); + + const autoConfirmFingerPrint = await firstValueFrom( + this.orgManagementPrefs.autoConfirmFingerPrints.state$, + ); + + const publicKeyResponse = await this.apiService.getUserPublicKey(user.userId); + const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey); + + if (autoConfirmFingerPrint == null || !autoConfirmFingerPrint) { + const fingerprint = await this.keyService.getFingerprint(user.userId, publicKey); + this.logService.info(`User's fingerprint: ${fingerprint.join("-")}`); + + const confirmed = UserConfirmComponent.open(this.dialogService, { + data: { + name: this.userNamePipe.transform(user), + userId: user.userId, + publicKey: publicKey, + }, + }); + + if (!(await lastValueFrom(confirmed.closed))) { + return; + } + } + + return publicKey; + } catch (e) { + this.logService.error(`Handled exception: ${e}`); + } + } } diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts index 1e229b95d24..08503f80f17 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts @@ -6,7 +6,9 @@ import { OrganizationUserStatusType, OrganizationUserType, } from "@bitwarden/common/admin-console/enums"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/logging"; import { OrganizationUserView } from "../../../core"; import { UserStatusPipe } from "../../pipes"; @@ -16,9 +18,13 @@ import { MemberExportService } from "./member-export.service"; describe("MemberExportService", () => { let service: MemberExportService; let i18nService: MockProxy; + let fileDownloadService: MockProxy; + let logService: MockProxy; beforeEach(() => { i18nService = mock(); + fileDownloadService = mock(); + logService = mock(); // Setup common i18n translations i18nService.t.mockImplementation((key: string) => { @@ -44,9 +50,12 @@ describe("MemberExportService", () => { custom: "Custom", // Boolean states enabled: "Enabled", + optionEnabled: "Enabled", disabled: "Disabled", enrolled: "Enrolled", notEnrolled: "Not Enrolled", + // Error messages + noMembersToExport: "No members to export", }; return translations[key] || key; }); @@ -54,6 +63,8 @@ describe("MemberExportService", () => { TestBed.configureTestingModule({ providers: [ MemberExportService, + { provide: FileDownloadService, useValue: fileDownloadService }, + { provide: LogService, useValue: logService }, { provide: I18nService, useValue: i18nService }, UserTypePipe, UserStatusPipe, @@ -88,8 +99,18 @@ describe("MemberExportService", () => { } as OrganizationUserView, ]; - const csvData = service.getMemberExport(members); + const result = service.getMemberExport(members); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + expect(fileDownloadService.download).toHaveBeenCalledTimes(1); + + const downloadCall = fileDownloadService.download.mock.calls[0][0]; + expect(downloadCall.fileName).toContain("org-members"); + expect(downloadCall.fileName).toContain(".csv"); + expect(downloadCall.blobOptions).toEqual({ type: "text/plain" }); + + const csvData = downloadCall.blobData as string; expect(csvData).toContain("Email,Name,Status,Role,Two-step Login,Account Recovery"); expect(csvData).toContain("user1@example.com"); expect(csvData).toContain("User One"); @@ -114,8 +135,12 @@ describe("MemberExportService", () => { } as OrganizationUserView, ]; - const csvData = service.getMemberExport(members); + const result = service.getMemberExport(members); + expect(result.success).toBe(true); + expect(fileDownloadService.download).toHaveBeenCalled(); + + const csvData = fileDownloadService.download.mock.calls[0][0].blobData as string; expect(csvData).toContain("user@example.com"); // Empty name is represented as an empty field in CSV expect(csvData).toContain("user@example.com,,Confirmed"); @@ -135,17 +160,23 @@ describe("MemberExportService", () => { } as OrganizationUserView, ]; - const csvData = service.getMemberExport(members); + const result = service.getMemberExport(members); + expect(result.success).toBe(true); + expect(fileDownloadService.download).toHaveBeenCalled(); + + const csvData = fileDownloadService.download.mock.calls[0][0].blobData as string; expect(csvData).toContain("user@example.com"); expect(csvData).toBeDefined(); }); it("should handle empty members array", () => { - const csvData = service.getMemberExport([]); + const result = service.getMemberExport([]); - // When array is empty, papaparse returns an empty string - expect(csvData).toBe(""); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe("No members to export"); + expect(fileDownloadService.download).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts index c00881617a4..069c7d9d148 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts @@ -2,7 +2,9 @@ import { inject, Injectable } from "@angular/core"; import * as papa from "papaparse"; import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { ExportHelper } from "@bitwarden/vault-export-core"; import { OrganizationUserView } from "../../../core"; @@ -10,40 +12,71 @@ import { UserStatusPipe } from "../../pipes"; import { MemberExport } from "./member.export"; +export interface MemberExportResult { + success: boolean; + error?: { message: string }; +} + @Injectable() export class MemberExportService { private i18nService = inject(I18nService); private userTypePipe = inject(UserTypePipe); private userStatusPipe = inject(UserStatusPipe); + private fileDownloadService = inject(FileDownloadService); + private logService = inject(LogService); - getMemberExport(members: OrganizationUserView[]): string { - const exportData = members.map((m) => - MemberExport.fromOrganizationUserView( - this.i18nService, - this.userTypePipe, - this.userStatusPipe, - m, - ), - ); + getMemberExport(data: OrganizationUserView[]): MemberExportResult { + try { + const members = data; + if (!members || members.length === 0) { + return { success: false, error: { message: this.i18nService.t("noMembersToExport") } }; + } - const headers: string[] = [ - this.i18nService.t("email"), - this.i18nService.t("name"), - this.i18nService.t("status"), - this.i18nService.t("role"), - this.i18nService.t("twoStepLogin"), - this.i18nService.t("accountRecovery"), - this.i18nService.t("secretsManager"), - this.i18nService.t("groups"), - ]; + const exportData = members.map((m) => + MemberExport.fromOrganizationUserView( + this.i18nService, + this.userTypePipe, + this.userStatusPipe, + m, + ), + ); - return papa.unparse(exportData, { - columns: headers, - header: true, - }); + const headers: string[] = [ + this.i18nService.t("email"), + this.i18nService.t("name"), + this.i18nService.t("status"), + this.i18nService.t("role"), + this.i18nService.t("twoStepLogin"), + this.i18nService.t("accountRecovery"), + this.i18nService.t("secretsManager"), + this.i18nService.t("groups"), + ]; + + const csvData = papa.unparse(exportData, { + columns: headers, + header: true, + }); + + const fileName = this.getFileName("org-members"); + + this.fileDownloadService.download({ + fileName: fileName, + blobData: csvData, + blobOptions: { type: "text/plain" }, + }); + + return { success: true }; + } catch (error) { + this.logService.error(`Failed to export members: ${error}`); + + const errorMessage = + error instanceof Error ? error.message : this.i18nService.t("unexpectedError"); + + return { success: false, error: { message: errorMessage } }; + } } - getFileName(prefix: string | null = null, extension = "csv"): string { + private getFileName(prefix: string | null = null, extension = "csv"): string { return ExportHelper.getFileName(prefix ?? "", extension); } } diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts index 0dc417cc2c6..b2695b7568f 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts @@ -1,14 +1,13 @@ import { Injectable } from "@angular/core"; import { combineLatest, firstValueFrom, from, map, switchMap } from "rxjs"; +import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { + CollectionDetailsResponse, Collection, CollectionData, - CollectionDetailsResponse, - CollectionService, - OrganizationUserApiService, -} from "@bitwarden/admin-console/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts index 45691ae98d6..9755beefbf1 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts @@ -1,13 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { - CollectionAccessSelectionView, - OrganizationUserUserDetailsResponse, -} from "@bitwarden/admin-console/common"; +import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common"; import { OrganizationUserStatusType, OrganizationUserType, } from "@bitwarden/common/admin-console/enums"; +import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections"; import { SelectItemView } from "@bitwarden/components"; import { GroupView } from "../../../core"; diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts index 7b189270e1b..4f40ea701d2 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -18,19 +18,21 @@ import { import { first } from "rxjs/operators"; import { - CollectionAccessSelectionView, CollectionAdminService, - CollectionAdminView, OrganizationUserApiService, OrganizationUserUserMiniResponse, - CollectionResponse, - CollectionView, CollectionService, } from "@bitwarden/admin-console/common"; import { getOrganizationById, OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { + CollectionAccessSelectionView, + CollectionAdminView, + CollectionView, + CollectionResponse, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; diff --git a/apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.ts b/apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.ts index 7132428c375..32a5736748c 100644 --- a/apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.ts +++ b/apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.ts @@ -1,7 +1,7 @@ import { AbstractControl, AsyncValidatorFn, FormControl, ValidationErrors } from "@angular/forms"; import { combineLatest, map, Observable, of } from "rxjs"; -import { Collection } from "@bitwarden/admin-console/common"; +import { Collection } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { getById } from "@bitwarden/common/platform/misc"; diff --git a/apps/web/src/app/billing/clients/account-billing.client.ts b/apps/web/src/app/billing/clients/account-billing.client.ts index 256a06b3ead..e520e70bf70 100644 --- a/apps/web/src/app/billing/clients/account-billing.client.ts +++ b/apps/web/src/app/billing/clients/account-billing.client.ts @@ -1,6 +1,8 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response"; +import { BitwardenSubscription } from "@bitwarden/subscription"; import { BillingAddress, @@ -11,13 +13,22 @@ import { @Injectable() export class AccountBillingClient { private endpoint = "/account/billing/vnext"; - private apiService: ApiService; - constructor(apiService: ApiService) { - this.apiService = apiService; - } + constructor(private apiService: ApiService) {} - purchasePremiumSubscription = async ( + getLicense = async (): Promise => { + const path = `${this.endpoint}/license`; + return this.apiService.send("GET", path, null, true, true); + }; + + getSubscription = async (): Promise => { + const path = `${this.endpoint}/subscription`; + const json = await this.apiService.send("GET", path, null, true, true); + const response = new BitwardenSubscriptionResponse(json); + return response.toDomain(); + }; + + purchaseSubscription = async ( paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod, billingAddress: Pick, ): Promise => { @@ -29,6 +40,17 @@ export class AccountBillingClient { const request = isTokenizedPayment ? { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress } : { nonTokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress }; + await this.apiService.send("POST", path, request, true, true); }; + + reinstateSubscription = async (): Promise => { + const path = `${this.endpoint}/subscription/reinstate`; + await this.apiService.send("POST", path, null, true, false); + }; + + updateSubscriptionStorage = async (additionalStorageGb: number): Promise => { + const path = `${this.endpoint}/subscription/storage`; + await this.apiService.send("PUT", path, { additionalStorageGb }, true, false); + }; } diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index fbaf65d1839..f85dab54fe7 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -1,9 +1,12 @@ import { inject, NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component"; import { SelfHostedPremiumComponent } from "@bitwarden/web-vault/app/billing/individual/premium/self-hosted-premium.component"; +import { AccountSubscriptionComponent } from "@bitwarden/web-vault/app/billing/individual/subscription/account-subscription.component"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component"; @@ -17,11 +20,15 @@ const routes: Routes = [ data: { titleId: "subscription" }, children: [ { path: "", pathMatch: "full", redirectTo: "premium" }, - { - path: "user-subscription", - component: UserSubscriptionComponent, - data: { titleId: "premiumMembership" }, - }, + ...featureFlaggedRoute({ + defaultComponent: UserSubscriptionComponent, + flaggedComponent: AccountSubscriptionComponent, + featureFlag: FeatureFlag.PM29594_UpdateIndividualSubscriptionPage, + routeOptions: { + path: "user-subscription", + data: { titleId: "premiumMembership" }, + }, + }), /** * Two-Route Matching Strategy for /premium: * diff --git a/apps/web/src/app/billing/individual/subscription/account-subscription.component.html b/apps/web/src/app/billing/individual/subscription/account-subscription.component.html new file mode 100644 index 00000000000..9bb788c1f36 --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/account-subscription.component.html @@ -0,0 +1,50 @@ +@if (subscriptionLoading()) { + + + {{ "loading" | i18n }} + +} @else { + @if (subscription.value(); as subscription) { + +
    +

    {{ "youHavePremium" | i18n }}

    +

    + {{ "viewAndManagePremiumSubscription" | i18n }} +

    +
    + + +
    + + + + + @if (subscription.storage; as storage) { + + } + + + +
    + } +} diff --git a/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts b/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts new file mode 100644 index 00000000000..183f4f82666 --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts @@ -0,0 +1,291 @@ +import { ChangeDetectionStrategy, Component, computed, inject, resource } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom, lastValueFrom, map } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService, TypographyModule } from "@bitwarden/components"; +import { Maybe } from "@bitwarden/pricing"; +import { + AdditionalOptionsCardAction, + AdditionalOptionsCardActions, + AdditionalOptionsCardComponent, + MAX_STORAGE_GB, + Storage, + StorageCardAction, + StorageCardActions, + StorageCardComponent, + SubscriptionCardAction, + SubscriptionCardActions, + SubscriptionCardComponent, + SubscriptionStatuses, +} from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + AdjustAccountSubscriptionStorageDialogComponent, + AdjustAccountSubscriptionStorageDialogParams, +} from "@bitwarden/web-vault/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component"; +import { + OffboardingSurveyDialogResultType, + openOffboardingSurvey, +} from "@bitwarden/web-vault/app/billing/shared/offboarding-survey.component"; + +@Component({ + templateUrl: "./account-subscription.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + AdditionalOptionsCardComponent, + I18nPipe, + JslibModule, + StorageCardComponent, + SubscriptionCardComponent, + TypographyModule, + ], + providers: [AccountBillingClient], +}) +export class AccountSubscriptionComponent { + private accountService = inject(AccountService); + private activatedRoute = inject(ActivatedRoute); + private accountBillingClient = inject(AccountBillingClient); + private billingAccountProfileStateService = inject(BillingAccountProfileStateService); + private configService = inject(ConfigService); + private dialogService = inject(DialogService); + private fileDownloadService = inject(FileDownloadService); + private i18nService = inject(I18nService); + private router = inject(Router); + private subscriptionPricingService = inject(SubscriptionPricingServiceAbstraction); + private toastService = inject(ToastService); + + readonly subscription = resource({ + loader: async () => { + const redirectToPremiumPage = async (): Promise => { + await this.router.navigate(["/settings/subscription/premium"]); + return null; + }; + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return await redirectToPremiumPage(); + } + const hasPremiumPersonally = await firstValueFrom( + this.billingAccountProfileStateService.hasPremiumPersonally$(account.id), + ); + if (!hasPremiumPersonally) { + return await redirectToPremiumPage(); + } + return await this.accountBillingClient.getSubscription(); + }, + }); + + readonly subscriptionLoading = computed(() => this.subscription.isLoading()); + + readonly subscriptionTerminal = computed>(() => { + const subscription = this.subscription.value(); + if (subscription) { + return ( + subscription.status === SubscriptionStatuses.IncompleteExpired || + subscription.status === SubscriptionStatuses.Canceled || + subscription.status === SubscriptionStatuses.Unpaid + ); + } + }); + + readonly subscriptionPendingCancellation = computed>(() => { + const subscription = this.subscription.value(); + if (subscription) { + return ( + (subscription.status === SubscriptionStatuses.Trialing || + subscription.status === SubscriptionStatuses.Active) && + !!subscription.cancelAt + ); + } + }); + + readonly storage = computed>(() => { + const subscription = this.subscription.value(); + return subscription?.storage; + }); + + readonly purchasedStorage = computed(() => { + const subscription = this.subscription.value(); + return subscription?.cart.passwordManager.additionalStorage?.quantity; + }); + + readonly premiumPlan = toSignal( + this.subscriptionPricingService + .getPersonalSubscriptionPricingTiers$() + .pipe( + map((tiers) => + tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium), + ), + ), + ); + + readonly premiumStoragePrice = computed>(() => { + const premiumPlan = this.premiumPlan(); + return premiumPlan?.passwordManager.annualPricePerAdditionalStorageGB; + }); + + readonly premiumProvidedStorage = computed>(() => { + const premiumPlan = this.premiumPlan(); + return premiumPlan?.passwordManager.providedStorageGB; + }); + + readonly canAddStorage = computed>(() => { + if (this.subscriptionTerminal()) { + return false; + } + const storage = this.storage(); + const premiumProvidedStorage = this.premiumProvidedStorage(); + if (storage && premiumProvidedStorage) { + const maxAttainableStorage = MAX_STORAGE_GB - premiumProvidedStorage; + return storage.available < maxAttainableStorage; + } + }); + + readonly canRemoveStorage = computed>(() => { + if (this.subscriptionTerminal()) { + return false; + } + const purchasedStorage = this.purchasedStorage(); + if (!purchasedStorage || purchasedStorage === 0) { + return false; + } + const storage = this.storage(); + if (storage) { + return storage.available > storage.used; + } + }); + + readonly canCancelSubscription = computed>(() => { + if (this.subscriptionTerminal()) { + return false; + } + return !this.subscriptionPendingCancellation(); + }); + + readonly premiumToOrganizationUpgradeEnabled = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.PM29593_PremiumToOrganizationUpgrade), + { initialValue: false }, + ); + + onSubscriptionCardAction = async (action: SubscriptionCardAction) => { + switch (action) { + case SubscriptionCardActions.ContactSupport: + window.open("https://bitwarden.com/contact/", "_blank"); + break; + case SubscriptionCardActions.ManageInvoices: + await this.router.navigate(["../billing-history"], { relativeTo: this.activatedRoute }); + break; + case SubscriptionCardActions.ReinstateSubscription: { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "reinstateSubscription" }, + content: { key: "reinstateConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + await this.accountBillingClient.reinstateSubscription(); + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("reinstated"), + }); + this.subscription.reload(); + break; + } + case SubscriptionCardActions.UpdatePayment: + await this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute }); + break; + case SubscriptionCardActions.UpgradePlan: + // TODO: Implement upgrade plan navigation + break; + } + }; + + onStorageCardAction = async (action: StorageCardAction) => { + const data = this.getAdjustStorageDialogParams(action); + const dialogReference = AdjustAccountSubscriptionStorageDialogComponent.open( + this.dialogService, + { + data, + }, + ); + const result = await lastValueFrom(dialogReference.closed); + if (result === "submitted") { + this.subscription.reload(); + } + }; + + onAdditionalOptionsCardAction = async (action: AdditionalOptionsCardAction) => { + switch (action) { + case AdditionalOptionsCardActions.DownloadLicense: { + const license = await this.accountBillingClient.getLicense(); + const json = JSON.stringify(license, null, 2); + this.fileDownloadService.download({ + fileName: "bitwarden_premium_license.json", + blobData: json, + }); + break; + } + case AdditionalOptionsCardActions.CancelSubscription: { + const dialogReference = openOffboardingSurvey(this.dialogService, { + data: { + type: "User", + }, + }); + + const result = await lastValueFrom(dialogReference.closed); + + if (result === OffboardingSurveyDialogResultType.Closed) { + return; + } + + this.subscription.reload(); + } + } + }; + + getAdjustStorageDialogParams = ( + action: StorageCardAction, + ): Maybe => { + const purchasedStorage = this.purchasedStorage(); + const storagePrice = this.premiumStoragePrice(); + const providedStorage = this.premiumProvidedStorage(); + + switch (action) { + case StorageCardActions.AddStorage: { + if (storagePrice && providedStorage) { + return { + type: "add", + price: storagePrice, + provided: providedStorage, + cadence: "annually", + existing: purchasedStorage, + }; + } + break; + } + case StorageCardActions.RemoveStorage: { + if (purchasedStorage) { + return { + type: "remove", + existing: purchasedStorage, + }; + } + break; + } + } + }; +} diff --git a/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.html b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.html new file mode 100644 index 00000000000..1d3172837ad --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.html @@ -0,0 +1,43 @@ +@let content = this.content(); +
    + + +

    {{ content.body }}

    +
    + + {{ content.label }} + + @if (action() === "add") { + + + {{ "total" | i18n }} + {{ formGroup.value.amount }} GB × {{ price() | currency: "$" }} = + {{ price() * formGroup.value.amount | currency: "$" }} / + {{ term() | i18n }} + + } + +
    +
    + + + + +
    +
    diff --git a/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts new file mode 100644 index 00000000000..f1350cda49e --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts @@ -0,0 +1,182 @@ +import { CurrencyPipe } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; + +import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + AsyncActionsModule, + ButtonModule, + DIALOG_DATA, + DialogConfig, + DialogModule, + DialogRef, + DialogService, + FormFieldModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { Maybe } from "@bitwarden/pricing"; +import { MAX_STORAGE_GB } from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients"; + +type RemoveStorage = { + type: "remove"; + existing: number; +}; + +type AddStorage = { + type: "add"; + price: number; + provided: number; + cadence: SubscriptionCadence; + existing?: number; +}; + +export type AdjustAccountSubscriptionStorageDialogParams = RemoveStorage | AddStorage; + +type AdjustAccountSubscriptionStorageDialogResult = "closed" | "submitted"; + +@Component({ + templateUrl: "./adjust-account-subscription-storage-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [AccountBillingClient], + imports: [ + AsyncActionsModule, + ButtonModule, + CurrencyPipe, + DialogModule, + FormFieldModule, + I18nPipe, + ReactiveFormsModule, + TypographyModule, + ], +}) +export class AdjustAccountSubscriptionStorageDialogComponent { + private readonly accountBillingClient = inject(AccountBillingClient); + private readonly dialogParams = inject(DIALOG_DATA); + private readonly dialogRef = inject(DialogRef); + private readonly i18nService = inject(I18nService); + private readonly toastService = inject(ToastService); + + readonly action = computed<"add" | "remove">(() => this.dialogParams.type); + + readonly price = computed>(() => { + if (this.dialogParams.type === "add") { + return this.dialogParams.price; + } + }); + + readonly provided = computed>(() => { + if (this.dialogParams.type === "add") { + return this.dialogParams.provided; + } + }); + + readonly term = computed>(() => { + if (this.dialogParams.type === "add") { + switch (this.dialogParams.cadence) { + case "annually": + return this.i18nService.t("year"); + case "monthly": + return this.i18nService.t("month"); + } + } + }); + + readonly existing = computed>(() => this.dialogParams.existing); + + readonly content = computed<{ + title: string; + body: string; + label: string; + }>(() => { + const action = this.action(); + switch (action) { + case "add": + return { + title: this.i18nService.t("addStorage"), + body: this.i18nService.t("storageAddNote"), + label: this.i18nService.t("gbStorageAdd"), + }; + case "remove": + return { + title: this.i18nService.t("removeStorage"), + body: this.i18nService.t("whenYouRemoveStorage"), + label: this.i18nService.t("gbStorageRemove"), + }; + } + }); + + readonly maxPurchasable = computed>(() => { + const provided = this.provided(); + if (provided) { + return MAX_STORAGE_GB - provided; + } + }); + + readonly maxValidatorValue = computed(() => { + const maxPurchasable = this.maxPurchasable() ?? MAX_STORAGE_GB; + const existing = this.existing(); + const action = this.action(); + + switch (action) { + case "add": { + return existing ? maxPurchasable - existing : maxPurchasable; + } + case "remove": { + return existing ? existing : 0; + } + } + }); + + formGroup = new FormGroup({ + amount: new FormControl(1, { + nonNullable: true, + validators: [ + Validators.required, + Validators.min(1), + Validators.max(this.maxValidatorValue()), + ], + }), + }); + + submit = async () => { + this.formGroup.markAllAsTouched(); + if (!this.formGroup.valid || !this.formGroup.value.amount) { + return; + } + + const action = this.action(); + const existing = this.existing(); + const amount = this.formGroup.value.amount; + + switch (action) { + case "add": { + await this.accountBillingClient.updateSubscriptionStorage(amount + (existing ?? 0)); + break; + } + case "remove": { + await this.accountBillingClient.updateSubscriptionStorage(existing! - amount); + } + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("adjustedStorage", amount), + }); + + this.dialogRef.close("submitted"); + }; + + static open = ( + dialogService: DialogService, + dialogConfig: DialogConfig, + ) => + dialogService.open( + AdjustAccountSubscriptionStorageDialogComponent, + dialogConfig, + ); +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 81169d719b6..83440646b48 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -478,13 +478,13 @@ describe("UpgradePaymentService", () => { describe("upgradeToPremium", () => { it("should call accountBillingClient to purchase premium subscription and refresh data", async () => { // Arrange - mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + mockAccountBillingClient.purchaseSubscription.mockResolvedValue(); // Act await sut.upgradeToPremium(mockTokenizedPaymentMethod, mockBillingAddress); // Assert - expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + expect(mockAccountBillingClient.purchaseSubscription).toHaveBeenCalledWith( mockTokenizedPaymentMethod, mockBillingAddress, ); @@ -496,13 +496,13 @@ describe("UpgradePaymentService", () => { const accountCreditPaymentMethod: NonTokenizedPaymentMethod = { type: NonTokenizablePaymentMethods.accountCredit, }; - mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + mockAccountBillingClient.purchaseSubscription.mockResolvedValue(); // Act await sut.upgradeToPremium(accountCreditPaymentMethod, mockBillingAddress); // Assert - expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + expect(mockAccountBillingClient.purchaseSubscription).toHaveBeenCalledWith( accountCreditPaymentMethod, mockBillingAddress, ); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index ae18ab4c629..b8d5637e471 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -143,7 +143,7 @@ export class UpgradePaymentService { ): Promise { this.validatePaymentAndBillingInfo(paymentMethod, billingAddress); - await this.accountBillingClient.purchasePremiumSubscription(paymentMethod, billingAddress); + await this.accountBillingClient.purchaseSubscription(paymentMethod, billingAddress); await this.refreshAndSync(); } diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index 34362b4be3e..77ae3b31837 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -142,7 +142,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { if (!this.selectedPlan()) { return { passwordManager: { - seats: { name: "", cost: 0, quantity: 0 }, + seats: { translationKey: "", cost: 0, quantity: 0 }, }, cadence: "annually", estimatedTax: 0, @@ -152,7 +152,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { return { passwordManager: { seats: { - name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + translationKey: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", cost: this.selectedPlan()!.details.passwordManager.annualPrice ?? 0, quantity: 1, }, diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index 307f170f116..f060d29b377 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -40,13 +40,13 @@
    {{ "status" | i18n }}
    -
    +
    {{ (subscription && subscriptionStatus) || "-" }} {{ "pendingCancellation" | i18n }}
    diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 2fc39218cf8..5034b21d03d 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -30,6 +30,7 @@ import { import { UpdateLicenseDialogComponent } from "../shared/update-license-dialog.component"; import { UpdateLicenseDialogResult } from "../shared/update-license-types"; +// TODO: Remove with deletion of pm-29594-update-individual-subscription-page // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -256,8 +257,8 @@ export class UserSubscriptionComponent implements OnInit { return null; } return discount.amountOff - ? { type: DiscountTypes.AmountOff, active: discount.active, value: discount.amountOff } - : { type: DiscountTypes.PercentOff, active: discount.active, value: discount.percentOff }; + ? { type: DiscountTypes.AmountOff, value: discount.amountOff } + : { type: DiscountTypes.PercentOff, value: discount.percentOff }; } get isSubscriptionActive(): boolean { diff --git a/apps/web/src/app/dirt/reports/pages/breach-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/breach-report.component.spec.ts index 886267e3189..6a26d8abd74 100644 --- a/apps/web/src/app/dirt/reports/pages/breach-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/breach-report.component.spec.ts @@ -1,17 +1,17 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore +import { Component, ChangeDetectionStrategy } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ReactiveFormsModule } from "@angular/forms"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BreachAccountResponse } from "@bitwarden/common/dirt/models/response/breach-account.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { AsyncActionsModule, ButtonModule, FormFieldModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { BreachReportComponent } from "./breach-report.component"; @@ -32,6 +32,21 @@ const breachedAccounts = [ }), ]; +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-header", + template: "
    ", + standalone: false, +}) +class MockHeaderComponent {} +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "bit-container", + template: "
    ", + standalone: false, +}) +class MockBitContainerComponent {} + describe("BreachReportComponent", () => { let component: BreachReportComponent; let fixture: ComponentFixture; @@ -51,8 +66,8 @@ describe("BreachReportComponent", () => { accountService.activeAccount$ = activeAccountSubject; await TestBed.configureTestingModule({ - declarations: [BreachReportComponent, I18nPipe], - imports: [ReactiveFormsModule], + declarations: [BreachReportComponent, MockHeaderComponent, MockBitContainerComponent], + imports: [ReactiveFormsModule, I18nPipe, AsyncActionsModule, ButtonModule, FormFieldModule], providers: [ { provide: AuditService, @@ -67,9 +82,7 @@ describe("BreachReportComponent", () => { useValue: mock(), }, ], - // FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports - errorOnUnknownElements: false, - errorOnUnknownProperties: false, + schemas: [], }).compileComponents(); }); diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.spec.ts index 560245bdc34..e056ec44af5 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.spec.ts @@ -1,8 +1,8 @@ +import { Component, ChangeDetectionStrategy } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -12,7 +12,13 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp import { UserId } 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 { DialogService } from "@bitwarden/components"; +import { + DialogService, + AsyncActionsModule, + ButtonModule, + FormFieldModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; @@ -20,6 +26,22 @@ import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/se import { ExposedPasswordsReportComponent } from "./exposed-passwords-report.component"; import { cipherData } from "./reports-ciphers.mock"; +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-header", + template: "
    ", + standalone: false, +}) +class MockHeaderComponent {} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "bit-container", + template: "
    ", + standalone: false, +}) +class MockBitContainerComponent {} + describe("ExposedPasswordsReportComponent", () => { let component: ExposedPasswordsReportComponent; let fixture: ComponentFixture; @@ -30,16 +52,19 @@ describe("ExposedPasswordsReportComponent", () => { const userId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(userId); - beforeEach(() => { + beforeEach(async () => { let cipherFormConfigServiceMock: MockProxy; syncServiceMock = mock(); auditService = mock(); organizationService = mock(); organizationService.organizations$.mockReturnValue(of([])); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - TestBed.configureTestingModule({ - declarations: [ExposedPasswordsReportComponent, I18nPipe], + await TestBed.configureTestingModule({ + declarations: [ + ExposedPasswordsReportComponent, + MockHeaderComponent, + MockBitContainerComponent, + ], + imports: [I18nPipe, AsyncActionsModule, ButtonModule, FormFieldModule], providers: [ { provide: CipherService, @@ -83,9 +108,6 @@ describe("ExposedPasswordsReportComponent", () => { }, ], schemas: [], - // FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports - errorOnUnknownElements: false, - errorOnUnknownProperties: false, }).compileComponents(); }); diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts index 64a851e120e..12453ea3b88 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts @@ -3,7 +3,6 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { MockProxy, mock } from "jest-mock-extended"; import { of } from "rxjs"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; 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"; @@ -14,6 +13,7 @@ import { UserId } 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 { DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; @@ -37,7 +37,8 @@ describe("InactiveTwoFactorReportComponent", () => { syncServiceMock = mock(); await TestBed.configureTestingModule({ - declarations: [InactiveTwoFactorReportComponent, I18nPipe], + declarations: [InactiveTwoFactorReportComponent], + imports: [I18nPipe], providers: [ { provide: CipherService, 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 f83614557bd..1d3d8d71f5a 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 @@ -17,14 +17,17 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv 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 { PasswordRepromptService, CipherFormConfigService } from "@bitwarden/vault"; +import { + PasswordRepromptService, + CipherFormConfigService, + RoutedVaultFilterBridgeService, + RoutedVaultFilterService, +} from "@bitwarden/vault"; import { HeaderModule } from "../../../../layouts/header/header.module"; import { SharedModule } from "../../../../shared"; import { OrganizationBadgeModule } from "../../../../vault/individual-vault/organization-badge/organization-badge.module"; import { PipesModule } from "../../../../vault/individual-vault/pipes/pipes.module"; -import { RoutedVaultFilterBridgeService } from "../../../../vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service"; -import { RoutedVaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/routed-vault-filter.service"; import { AdminConsoleCipherFormConfigService } from "../../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { ExposedPasswordsReportComponent as BaseExposedPasswordsReportComponent } from "../exposed-passwords-report.component"; 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 b1adbd26eb3..23d1330dad7 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 @@ -12,14 +12,17 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv 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 { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; +import { + CipherFormConfigService, + PasswordRepromptService, + RoutedVaultFilterBridgeService, + RoutedVaultFilterService, +} from "@bitwarden/vault"; import { HeaderModule } from "../../../../layouts/header/header.module"; import { SharedModule } from "../../../../shared"; import { OrganizationBadgeModule } from "../../../../vault/individual-vault/organization-badge/organization-badge.module"; import { PipesModule } from "../../../../vault/individual-vault/pipes/pipes.module"; -import { RoutedVaultFilterBridgeService } from "../../../../vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service"; -import { RoutedVaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/routed-vault-filter.service"; import { AdminConsoleCipherFormConfigService } from "../../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { InactiveTwoFactorReportComponent as BaseInactiveTwoFactorReportComponent } from "../inactive-two-factor-report.component"; 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 3944e2edfcb..599774d5515 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 @@ -16,14 +16,17 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv 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 { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; +import { + CipherFormConfigService, + PasswordRepromptService, + RoutedVaultFilterBridgeService, + RoutedVaultFilterService, +} from "@bitwarden/vault"; import { HeaderModule } from "../../../../layouts/header/header.module"; import { SharedModule } from "../../../../shared"; import { OrganizationBadgeModule } from "../../../../vault/individual-vault/organization-badge/organization-badge.module"; import { PipesModule } from "../../../../vault/individual-vault/pipes/pipes.module"; -import { RoutedVaultFilterBridgeService } from "../../../../vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service"; -import { RoutedVaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/routed-vault-filter.service"; import { AdminConsoleCipherFormConfigService } from "../../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { ReusedPasswordsReportComponent as BaseReusedPasswordsReportComponent } from "../reused-passwords-report.component"; 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 d49baa5d465..6bf741b86eb 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 @@ -16,14 +16,17 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv 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 { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; +import { + CipherFormConfigService, + PasswordRepromptService, + RoutedVaultFilterBridgeService, + RoutedVaultFilterService, +} from "@bitwarden/vault"; import { HeaderModule } from "../../../../layouts/header/header.module"; import { SharedModule } from "../../../../shared"; import { OrganizationBadgeModule } from "../../../../vault/individual-vault/organization-badge/organization-badge.module"; import { PipesModule } from "../../../../vault/individual-vault/pipes/pipes.module"; -import { RoutedVaultFilterBridgeService } from "../../../../vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service"; -import { RoutedVaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/routed-vault-filter.service"; import { AdminConsoleCipherFormConfigService } from "../../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { UnsecuredWebsitesReportComponent as BaseUnsecuredWebsitesReportComponent } from "../unsecured-websites-report.component"; 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 5158416dd28..6780b65931c 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 @@ -17,14 +17,17 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv 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 { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; +import { + CipherFormConfigService, + PasswordRepromptService, + RoutedVaultFilterBridgeService, + RoutedVaultFilterService, +} from "@bitwarden/vault"; import { HeaderModule } from "../../../../layouts/header/header.module"; import { SharedModule } from "../../../../shared"; import { OrganizationBadgeModule } from "../../../../vault/individual-vault/organization-badge/organization-badge.module"; import { PipesModule } from "../../../../vault/individual-vault/pipes/pipes.module"; -import { RoutedVaultFilterBridgeService } from "../../../../vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service"; -import { RoutedVaultFilterService } from "../../../../vault/individual-vault/vault-filter/services/routed-vault-filter.service"; import { AdminConsoleCipherFormConfigService } from "../../../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { WeakPasswordsReportComponent as BaseWeakPasswordsReportComponent } from "../weak-passwords-report.component"; diff --git a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.spec.ts index 5933d2ce293..1b7006d0c68 100644 --- a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.spec.ts @@ -1,8 +1,8 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { MockProxy, mock } from "jest-mock-extended"; import { of } from "rxjs"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; 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"; @@ -12,6 +12,7 @@ import { UserId } 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 { DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; @@ -19,6 +20,22 @@ import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/se import { cipherData } from "./reports-ciphers.mock"; import { ReusedPasswordsReportComponent } from "./reused-passwords-report.component"; +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-header", + template: "
    ", + standalone: false, +}) +class MockHeaderComponent {} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "bit-container", + template: "
    ", + standalone: false, +}) +class MockBitContainerComponent {} + describe("ReusedPasswordsReportComponent", () => { let component: ReusedPasswordsReportComponent; let fixture: ComponentFixture; @@ -28,15 +45,18 @@ describe("ReusedPasswordsReportComponent", () => { const userId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(userId); - beforeEach(() => { + beforeEach(async () => { let cipherFormConfigServiceMock: MockProxy; organizationService = mock(); organizationService.organizations$.mockReturnValue(of([])); syncServiceMock = mock(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - TestBed.configureTestingModule({ - declarations: [ReusedPasswordsReportComponent, I18nPipe], + await TestBed.configureTestingModule({ + declarations: [ + ReusedPasswordsReportComponent, + MockHeaderComponent, + MockBitContainerComponent, + ], + imports: [I18nPipe], providers: [ { provide: CipherService, @@ -76,8 +96,6 @@ describe("ReusedPasswordsReportComponent", () => { }, ], schemas: [], - // FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports - errorOnUnknownElements: false, }).compileComponents(); }); diff --git a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.spec.ts index 040d73a0d66..2107e0c8df7 100644 --- a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.spec.ts @@ -1,9 +1,9 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { MockProxy, mock } from "jest-mock-extended"; import { of } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; 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"; @@ -13,6 +13,7 @@ import { UserId } 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 { DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; @@ -20,6 +21,22 @@ import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/se import { cipherData } from "./reports-ciphers.mock"; import { UnsecuredWebsitesReportComponent } from "./unsecured-websites-report.component"; +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-header", + template: "
    ", + standalone: false, +}) +class MockHeaderComponent {} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "bit-container", + template: "
    ", + standalone: false, +}) +class MockBitContainerComponent {} + describe("UnsecuredWebsitesReportComponent", () => { let component: UnsecuredWebsitesReportComponent; let fixture: ComponentFixture; @@ -30,7 +47,7 @@ describe("UnsecuredWebsitesReportComponent", () => { const userId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(userId); - beforeEach(() => { + beforeEach(async () => { let cipherFormConfigServiceMock: MockProxy; organizationService = mock(); organizationService.organizations$.mockReturnValue(of([])); @@ -38,10 +55,13 @@ describe("UnsecuredWebsitesReportComponent", () => { collectionService = mock(); adminConsoleCipherFormConfigService = mock(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - TestBed.configureTestingModule({ - declarations: [UnsecuredWebsitesReportComponent, I18nPipe], + await TestBed.configureTestingModule({ + declarations: [ + UnsecuredWebsitesReportComponent, + MockHeaderComponent, + MockBitContainerComponent, + ], + imports: [I18nPipe], providers: [ { provide: CipherService, @@ -85,8 +105,6 @@ describe("UnsecuredWebsitesReportComponent", () => { }, ], schemas: [], - // FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports - errorOnUnknownElements: false, }).compileComponents(); }); diff --git a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.spec.ts index d78dc7e3ceb..a63723dc688 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.spec.ts @@ -1,8 +1,8 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; -import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; 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"; @@ -13,6 +13,7 @@ import { UserId } 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 { DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { CipherFormConfigService, PasswordRepromptService } from "@bitwarden/vault"; import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/services/admin-console-cipher-form-config.service"; @@ -20,6 +21,22 @@ import { AdminConsoleCipherFormConfigService } from "../../../vault/org-vault/se import { cipherData } from "./reports-ciphers.mock"; import { WeakPasswordsReportComponent } from "./weak-passwords-report.component"; +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "app-header", + template: "
    ", + standalone: false, +}) +class MockHeaderComponent {} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "bit-container", + template: "
    ", + standalone: false, +}) +class MockBitContainerComponent {} + describe("WeakPasswordsReportComponent", () => { let component: WeakPasswordsReportComponent; let fixture: ComponentFixture; @@ -30,16 +47,16 @@ describe("WeakPasswordsReportComponent", () => { const userId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(userId); - beforeEach(() => { + beforeEach(async () => { let cipherFormConfigServiceMock: MockProxy; syncServiceMock = mock(); passwordStrengthService = mock(); organizationService = mock(); organizationService.organizations$.mockReturnValue(of([])); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - TestBed.configureTestingModule({ - declarations: [WeakPasswordsReportComponent, I18nPipe], + + await TestBed.configureTestingModule({ + declarations: [WeakPasswordsReportComponent, MockHeaderComponent, MockBitContainerComponent], + imports: [I18nPipe], providers: [ { provide: CipherService, @@ -84,8 +101,6 @@ describe("WeakPasswordsReportComponent", () => { }, ], schemas: [], - // FIXME(PM-18598): Replace unknownElements and unknownProperties with actual imports - errorOnUnknownElements: false, }).compileComponents(); }); diff --git a/apps/web/src/app/dirt/reports/reports.module.ts b/apps/web/src/app/dirt/reports/reports.module.ts index 358768e71ee..5648b40982a 100644 --- a/apps/web/src/app/dirt/reports/reports.module.ts +++ b/apps/web/src/app/dirt/reports/reports.module.ts @@ -1,14 +1,17 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; -import { CipherFormConfigService, DefaultCipherFormConfigService } from "@bitwarden/vault"; +import { + CipherFormConfigService, + DefaultCipherFormConfigService, + RoutedVaultFilterBridgeService, + RoutedVaultFilterService, +} from "@bitwarden/vault"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; import { OrganizationBadgeModule } from "../../vault/individual-vault/organization-badge/organization-badge.module"; import { PipesModule } from "../../vault/individual-vault/pipes/pipes.module"; -import { RoutedVaultFilterBridgeService } from "../../vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service"; -import { RoutedVaultFilterService } from "../../vault/individual-vault/vault-filter/services/routed-vault-filter.service"; import { AdminConsoleCipherFormConfigService } from "../../vault/org-vault/services/admin-console-cipher-form-config.service"; import { BreachReportComponent } from "./pages/breach-report.component"; diff --git a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts index 9ae0600fb2a..7d6ad41cb00 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts @@ -122,6 +122,41 @@ describe("CipherStep", () => { expect(logger.record).toHaveBeenCalledWith("Cipher ID cipher-3 was undecryptable"); expect(logger.record).toHaveBeenCalledWith("Found 2 undecryptable ciphers"); }); + + it("returns correct results when running diagnostics multiple times", async () => { + const userId = "user-id" as UserId; + const cipher1 = { id: "cipher-1", organizationId: null } as Cipher; + const cipher2 = { id: "cipher-2", organizationId: null } as Cipher; + + const workingData: RecoveryWorkingData = { + userId, + userKey: null, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [cipher1, cipher2], + folders: [], + }; + + // First run: cipher1 succeeds, cipher2 fails + cipherEncryptionService.decrypt + .mockResolvedValueOnce({} as any) + .mockRejectedValueOnce(new Error("Decryption failed")); + + const result1 = await cipherStep.runDiagnostics(workingData, logger); + + expect(result1).toBe(false); + expect(cipherStep.canRecover(workingData)).toBe(true); + + // Second run: all ciphers succeed + cipherEncryptionService.decrypt.mockResolvedValue({} as any); + + const result2 = await cipherStep.runDiagnostics(workingData, logger); + + expect(result2).toBe(true); + expect(cipherStep.canRecover(workingData)).toBe(false); + expect(cipherStep["undecryptableCipherIds"]).toHaveLength(0); + expect(cipherStep["decryptableCipherIds"]).toHaveLength(2); + }); }); describe("canRecover", () => { diff --git a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts index 01c2d9bc2a1..47000f8880b 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts @@ -25,6 +25,7 @@ export class CipherStep implements RecoveryStep { } this.undecryptableCipherIds = []; + this.decryptableCipherIds = []; // The tool is currently only implemented to handle ciphers that are corrupt for a user. For an organization, the case of // local user not having access to the organization key is not properly handled here, and should be implemented separately. // For now, this just filters out and does not consider corrupt organization ciphers. diff --git a/apps/web/src/app/key-management/data-recovery/steps/folder-step.spec.ts b/apps/web/src/app/key-management/data-recovery/steps/folder-step.spec.ts new file mode 100644 index 00000000000..8e59007732e --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/folder-step.spec.ts @@ -0,0 +1,404 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { UserKey } from "@bitwarden/common/types/key"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { Folder } from "@bitwarden/common/vault/models/domain/folder"; +import { DialogService } from "@bitwarden/components"; +import { PureCrypto } from "@bitwarden/sdk-internal"; +import { UserId } from "@bitwarden/user-core"; + +import { LogRecorder } from "../log-recorder"; + +import { FolderStep } from "./folder-step"; +import { RecoveryWorkingData } from "./recovery-step"; + +// Mock SdkLoadService +jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk-load.service", () => ({ + SdkLoadService: { + Ready: Promise.resolve(), + }, +})); + +jest.mock("@bitwarden/sdk-internal", () => ({ + PureCrypto: { + symmetric_decrypt_string: jest.fn(), + }, +})); + +describe("FolderStep", () => { + let folderStep: FolderStep; + let folderService: MockProxy; + let dialogService: MockProxy; + let logger: MockProxy; + + const mockUserKey = { + toEncoded: jest.fn().mockReturnValue("encoded-user-key"), + } as unknown as UserKey; + + beforeEach(() => { + folderService = mock(); + dialogService = mock(); + logger = mock(); + + folderStep = new FolderStep(folderService, dialogService); + + jest.clearAllMocks(); + }); + + describe("runDiagnostics", () => { + it("returns false and logs error when userKey is missing", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: null, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [], + }; + + const result = await folderStep.runDiagnostics(workingData, logger); + + expect(result).toBe(false); + expect(logger.record).toHaveBeenCalledWith("Missing user key"); + }); + + it("returns true when all folders are decryptable", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockReturnValue("decrypted-name"); + + const result = await folderStep.runDiagnostics(workingData, logger); + + expect(result).toBe(true); + expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith( + "encrypted-name-1", + "encoded-user-key", + ); + expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith( + "encrypted-name-2", + "encoded-user-key", + ); + expect(logger.record).toHaveBeenCalledWith("Found 0 undecryptable folders"); + expect(logger.record).toHaveBeenCalledWith("Found 2 decryptable folders"); + }); + + it("returns false and records folders with no name", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: null as null }; + const folder3 = { id: "folder-3", name: { encryptedString: null as null } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2, folder3] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockReturnValue("decrypted-name"); + + const result = await folderStep.runDiagnostics(workingData, logger); + + expect(result).toBe(false); + expect(logger.record).toHaveBeenCalledWith("Folder ID folder-2 has no name"); + expect(logger.record).toHaveBeenCalledWith("Folder ID folder-3 has no name"); + expect(logger.record).toHaveBeenCalledWith("Found 2 undecryptable folders"); + expect(logger.record).toHaveBeenCalledWith("Found 1 decryptable folders"); + }); + + it("returns false and records undecryptable folders", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + const folder3 = { id: "folder-3", name: { encryptedString: "encrypted-name-3" } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2, folder3] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock) + .mockReturnValueOnce("decrypted-name") // folder1 succeeds + .mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }) // folder2 fails + .mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }); // folder3 fails + + const result = await folderStep.runDiagnostics(workingData, logger); + + expect(result).toBe(false); + expect(logger.record).toHaveBeenCalledWith( + "Folder name for folder ID folder-2 was undecryptable", + ); + expect(logger.record).toHaveBeenCalledWith( + "Folder name for folder ID folder-3 was undecryptable", + ); + expect(logger.record).toHaveBeenCalledWith("Found 2 undecryptable folders"); + expect(logger.record).toHaveBeenCalledWith("Found 1 decryptable folders"); + }); + + it("returns correct results when running diagnostics multiple times", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2] as Folder[], + }; + + // First run: folder1 succeeds, folder2 fails + (PureCrypto.symmetric_decrypt_string as jest.Mock) + .mockReturnValueOnce("decrypted-name") + .mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }); + + const result1 = await folderStep.runDiagnostics(workingData, logger); + + expect(result1).toBe(false); + expect(folderStep.canRecover(workingData)).toBe(true); + + // Second run: all folders succeed + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockReturnValue("decrypted-name"); + + const result2 = await folderStep.runDiagnostics(workingData, logger); + + expect(result2).toBe(true); + expect(folderStep.canRecover(workingData)).toBe(false); + expect(folderStep["undecryptableFolderIds"]).toEqual([]); + expect(folderStep["decryptableFolderIds"]).toEqual(["folder-1", "folder-2"]); + }); + }); + + describe("canRecover", () => { + it("returns false when there are no undecryptable folders", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [ + { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }, + { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }, + ] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockReturnValue("decrypted-name"); + + await folderStep.runDiagnostics(workingData, logger); + const result = folderStep.canRecover(workingData); + + expect(result).toBe(false); + }); + + it("returns true when there are undecryptable folders but at least one decryptable folder", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [ + { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }, + { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }, + ] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock) + .mockReturnValueOnce("decrypted-name") + .mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }); + + await folderStep.runDiagnostics(workingData, logger); + const result = folderStep.canRecover(workingData); + + expect(result).toBe(true); + }); + + it("returns false when all folders are undecryptable", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [ + { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }, + { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }, + ] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + + await folderStep.runDiagnostics(workingData, logger); + const result = folderStep.canRecover(workingData); + + expect(result).toBe(false); + }); + }); + + describe("runRecovery", () => { + it("logs and returns early when there are no undecryptable folders", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [], + }; + + await folderStep.runRecovery(workingData, logger); + + expect(logger.record).toHaveBeenCalledWith("No undecryptable folders to recover"); + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(folderService.delete).not.toHaveBeenCalled(); + }); + + it("throws error when userId is missing", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + await folderStep.runDiagnostics(workingData, logger); + + // Now set userId to null for recovery + workingData.userId = null; + + await expect(folderStep.runRecovery(workingData, logger)).rejects.toThrow("Missing user ID"); + expect(logger.record).toHaveBeenCalledWith("Missing user ID"); + }); + + it("throws error when user cancels deletion", async () => { + const userId = "user-id" as UserId; + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + + const workingData: RecoveryWorkingData = { + userId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + await folderStep.runDiagnostics(workingData, logger); + + dialogService.openSimpleDialog.mockResolvedValue(false); + + await expect(folderStep.runRecovery(workingData, logger)).rejects.toThrow( + "Folder recovery cancelled by user", + ); + + expect(logger.record).toHaveBeenCalledWith("Showing confirmation dialog for 1 folders"); + expect(logger.record).toHaveBeenCalledWith("User cancelled folder deletion"); + expect(folderService.delete).not.toHaveBeenCalled(); + }); + + it("deletes undecryptable folders when user confirms", async () => { + const userId = "user-id" as UserId; + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + + const workingData: RecoveryWorkingData = { + userId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + await folderStep.runDiagnostics(workingData, logger); + + dialogService.openSimpleDialog.mockResolvedValue(true); + folderService.delete.mockResolvedValue(undefined); + + await folderStep.runRecovery(workingData, logger); + + expect(logger.record).toHaveBeenCalledWith("Showing confirmation dialog for 2 folders"); + expect(logger.record).toHaveBeenCalledWith("Deleting 2 folders"); + expect(folderService.delete).toHaveBeenCalledWith("folder-1", userId); + expect(folderService.delete).toHaveBeenCalledWith("folder-2", userId); + expect(logger.record).toHaveBeenCalledWith("Deleted folder folder-1"); + expect(logger.record).toHaveBeenCalledWith("Deleted folder folder-2"); + expect(logger.record).toHaveBeenCalledWith("Successfully deleted 2 folders"); + }); + + it("continues deleting folders even if some deletions fail", async () => { + const userId = "user-id" as UserId; + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + const folder3 = { id: "folder-3", name: { encryptedString: "encrypted-name-3" } }; + + const workingData: RecoveryWorkingData = { + userId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2, folder3] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + await folderStep.runDiagnostics(workingData, logger); + + dialogService.openSimpleDialog.mockResolvedValue(true); + folderService.delete + .mockResolvedValueOnce(undefined) // folder1 succeeds + .mockRejectedValueOnce(new Error("Network error")) // folder2 fails + .mockResolvedValueOnce(undefined); // folder3 succeeds + + await folderStep.runRecovery(workingData, logger); + + expect(folderService.delete).toHaveBeenCalledTimes(3); + expect(logger.record).toHaveBeenCalledWith("Deleted folder folder-1"); + expect(logger.record).toHaveBeenCalledWith( + "Failed to delete folder folder-2: Error: Network error", + ); + expect(logger.record).toHaveBeenCalledWith("Deleted folder folder-3"); + }); + }); +}); diff --git a/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts index 90e252ce6c3..2087c360ddc 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts @@ -25,6 +25,8 @@ export class FolderStep implements RecoveryStep { } this.undecryptableFolderIds = []; + this.decryptableFolderIds = []; + for (const folder of workingData.folders) { if (!folder.name?.encryptedString) { logger.record(`Folder ID ${folder.id} has no name`); diff --git a/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts b/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts index 82c20c466b8..45c2c6e8a93 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts @@ -85,9 +85,14 @@ export class PrivateKeyStep implements RecoveryStep { } logger.record("Replacing private key"); - await this.privateKeyRegenerationService.regenerateUserPublicKeyEncryptionKeyPair( - workingData.userId!, - ); - logger.record("Private key replaced successfully"); + const recovered = + await this.privateKeyRegenerationService.regenerateUserPublicKeyEncryptionKeyPair( + workingData.userId!, + ); + if (!recovered) { + logger.record("Private key replacement could not be performed"); + } else { + logger.record("Private key replacement replaced successfully"); + } } } diff --git a/apps/web/src/app/tools/import/import-collection-admin.service.ts b/apps/web/src/app/tools/import/import-collection-admin.service.ts index b63cd15047b..1f77d99cace 100644 --- a/apps/web/src/app/tools/import/import-collection-admin.service.ts +++ b/apps/web/src/app/tools/import/import-collection-admin.service.ts @@ -1,7 +1,8 @@ import { Injectable } from "@angular/core"; import { firstValueFrom } from "rxjs"; -import { CollectionAdminService, CollectionAdminView } from "@bitwarden/admin-console/common"; +import { CollectionAdminService } from "@bitwarden/admin-console/common"; +import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections"; import { ImportCollectionServiceAbstraction } from "@bitwarden/importer-core"; import { UserId } from "@bitwarden/user-core"; diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts index 4f5dda1745e..e9ef85867e7 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts @@ -8,9 +8,9 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SendAddEditDialogComponent } from "@bitwarden/send-ui"; @@ -72,6 +72,7 @@ describe("NewSendDropdownComponent", () => { const openSpy = jest.spyOn(SendAddEditDialogComponent, "open"); const openDrawerSpy = jest.spyOn(SendAddEditDialogComponent, "openDrawer"); mockConfigService.getFeatureFlag.mockResolvedValue(false); + openSpy.mockReturnValue({ closed: of({}) } as any); await component.createSend(SendType.Text); @@ -85,6 +86,8 @@ describe("NewSendDropdownComponent", () => { mockConfigService.getFeatureFlag.mockImplementation(async (key) => key === FeatureFlag.SendUIRefresh ? true : false, ); + const mockRef = { closed: of({}) }; + openDrawerSpy.mockReturnValue(mockRef as any); await component.createSend(SendType.Text); diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts index 22f07e4fe92..68c8c188d31 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; -import { firstValueFrom, Observable, of, switchMap } from "rxjs"; +import { firstValueFrom, Observable, of, switchMap, lastValueFrom } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -8,9 +8,15 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv 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 { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components"; -import { DefaultSendFormConfigService, SendAddEditDialogComponent } from "@bitwarden/send-ui"; +import { + DefaultSendFormConfigService, + SendAddEditDialogComponent, + SendItemDialogResult, +} from "@bitwarden/send-ui"; + +import { SendSuccessDrawerDialogComponent } 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 @@ -60,12 +66,19 @@ export class NewSendDropdownComponent { if (!(await firstValueFrom(this.canAccessPremium$)) && type === SendType.File) { return; } - const formConfig = await this.addEditFormConfigService.buildConfig("add", undefined, type); - const useRefresh = await this.configService.getFeatureFlag(FeatureFlag.SendUIRefresh); + if (useRefresh) { - SendAddEditDialogComponent.openDrawer(this.dialogService, { formConfig }); + const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, { formConfig }); + if (dialogRef) { + const result = await lastValueFrom(dialogRef.closed); + if (result?.result === SendItemDialogResult.Saved && result?.send) { + this.dialogService.openDrawer(SendSuccessDrawerDialogComponent, { + data: result.send, + }); + } + } } else { SendAddEditDialogComponent.open(this.dialogService, { formConfig }); } diff --git a/apps/web/src/app/tools/send/send-access/authentication-flow.md b/apps/web/src/app/tools/send/send-access/authentication-flow.md deleted file mode 100644 index f39b43fcd41..00000000000 --- a/apps/web/src/app/tools/send/send-access/authentication-flow.md +++ /dev/null @@ -1,75 +0,0 @@ -# Send Authentication Flows - -In the below diagrams, activations represent client control flow. - -## Public Sends - -Anyone can access a public send. The token endpoint automatically issues a token. It never issues a challenge. - -```mermaid -sequenceDiagram - participant Visitor - participant TryAccess as try-send-access.guard - participant SendToken as send-token API - participant ViewContent as view-content.component - participant SendAccess as send-access API - - Visitor->>TryAccess: Navigate to send URL - activate TryAccess - TryAccess->>SendToken: Request anonymous access token - SendToken-->>TryAccess: OK + Security token - TryAccess->>ViewContent: Redirect with token - deactivate TryAccess - activate ViewContent - ViewContent->>SendAccess: Request send content (with token and key) - SendAccess-->>ViewContent: Return send content - ViewContent->>Visitor: Display send content - deactivate ViewContent -``` - -## Password Protected Sends - -Password protected sends redirect to a password challenge prompt. - -```mermaid -sequenceDiagram - participant Visitor - participant TryAccess as try-send-access.guard - participant PasswordAuth as password-authentication.component - participant SendToken as send-token API - participant ViewContent as view-content.component - participant SendAccess as send-access API - - Visitor->>TryAccess: Navigate to send URL - activate TryAccess - TryAccess->>SendToken: Request anonymous access token - SendToken-->>TryAccess: Unauthorized + Password challenge - TryAccess->>PasswordAuth: Redirect with send ID and key - deactivate TryAccess - activate PasswordAuth - PasswordAuth->>Visitor: Request password - Visitor-->>PasswordAuth: Enter password - PasswordAuth->>SendToken: Request access token (with password) - SendToken-->>PasswordAuth: OK + Security token - deactivate PasswordAuth - activate ViewContent - PasswordAuth->>ViewContent: Redirect with token and send key - ViewContent->>SendAccess: Request send content (with token) - SendAccess-->>ViewContent: Return send content - ViewContent->>Visitor: Display send content - deactivate ViewContent -``` - -## Send Access without token - -Visiting the view page without a token redirects to a try-access flow, above. - -```mermaid -sequenceDiagram - participant Visitor - participant ViewContent as view-content.component - participant TryAccess as try-send-access.guard - - Visitor->>ViewContent: Navigate to send URL (with id and key) - ViewContent->>TryAccess: Redirect to try-access (with id and key) -``` diff --git a/apps/web/src/app/tools/send/send-access/default-send-access-service.spec.ts b/apps/web/src/app/tools/send/send-access/default-send-access-service.spec.ts deleted file mode 100644 index cd07d3684fb..00000000000 --- a/apps/web/src/app/tools/send/send-access/default-send-access-service.spec.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { TestBed, fakeAsync, tick } from "@angular/core/testing"; -import { Router, UrlTree } from "@angular/router"; -import { mock, MockProxy } from "jest-mock-extended"; -import { firstValueFrom, NEVER } from "rxjs"; - -import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; -import { StateProvider } from "@bitwarden/common/platform/state"; -import { mockAccountServiceWith, FakeStateProvider } from "@bitwarden/common/spec"; -import { SemanticLogger } from "@bitwarden/common/tools/log"; -import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; -import { UserId } from "@bitwarden/common/types/guid"; -import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components"; - -import { DefaultSendAccessService } from "./default-send-access-service"; -import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY } from "./send-access-memory"; - -describe("DefaultSendAccessService", () => { - let service: DefaultSendAccessService; - let stateProvider: FakeStateProvider; - let sendApiService: MockProxy; - let router: MockProxy; - let logger: MockProxy; - let systemServiceProvider: MockProxy; - - beforeEach(() => { - const accountService = mockAccountServiceWith("user-id" as UserId); - stateProvider = new FakeStateProvider(accountService); - sendApiService = mock(); - router = mock(); - logger = mock(); - systemServiceProvider = mock(); - - systemServiceProvider.log.mockReturnValue(logger); - - TestBed.configureTestingModule({ - providers: [ - DefaultSendAccessService, - { provide: StateProvider, useValue: stateProvider }, - { provide: SendApiService, useValue: sendApiService }, - { provide: Router, useValue: router }, - { provide: SYSTEM_SERVICE_PROVIDER, useValue: systemServiceProvider }, - ], - }); - - service = TestBed.inject(DefaultSendAccessService); - }); - - describe("constructor", () => { - it("creates logger with type 'SendAccessAuthenticationService' when initialized", () => { - expect(systemServiceProvider.log).toHaveBeenCalledWith({ - type: "SendAccessAuthenticationService", - }); - }); - }); - - describe("redirect$", () => { - const sendId = "test-send-id"; - - it("returns content page UrlTree and logs info when API returns success", async () => { - const expectedUrlTree = { toString: () => "/send/content/test-send-id" } as UrlTree; - sendApiService.postSendAccess.mockResolvedValue({} as any); - router.createUrlTree.mockReturnValue(expectedUrlTree); - - const result = await firstValueFrom(service.redirect$(sendId)); - - expect(result).toBe(expectedUrlTree); - expect(logger.info).toHaveBeenCalledWith( - "public send detected; redirecting to send access with token.", - ); - }); - - describe("given error responses", () => { - it("returns password flow UrlTree and logs debug when 401 received", async () => { - const expectedUrlTree = { toString: () => "/send/test-send-id" } as UrlTree; - const errorResponse = new ErrorResponse([], 401); - sendApiService.postSendAccess.mockRejectedValue(errorResponse); - router.createUrlTree.mockReturnValue(expectedUrlTree); - - const result = await firstValueFrom(service.redirect$(sendId)); - - expect(result).toBe(expectedUrlTree); - expect(logger.debug).toHaveBeenCalledWith(errorResponse, "redirecting to password flow"); - }); - - it("returns 404 page UrlTree and logs debug when 404 received", async () => { - const expectedUrlTree = { toString: () => "/404.html" } as UrlTree; - const errorResponse = new ErrorResponse([], 404); - sendApiService.postSendAccess.mockRejectedValue(errorResponse); - router.parseUrl.mockReturnValue(expectedUrlTree); - - const result = await firstValueFrom(service.redirect$(sendId)); - - expect(result).toBe(expectedUrlTree); - expect(logger.debug).toHaveBeenCalledWith(errorResponse, "redirecting to unavailable page"); - }); - - it("logs warning and throws error when 500 received", async () => { - const errorResponse = new ErrorResponse([], 500); - sendApiService.postSendAccess.mockRejectedValue(errorResponse); - - await expect(firstValueFrom(service.redirect$(sendId))).rejects.toBe(errorResponse); - expect(logger.warn).toHaveBeenCalledWith( - errorResponse, - "received unexpected error response", - ); - }); - - it("throws error when unexpected error code received", async () => { - const errorResponse = new ErrorResponse([], 403); - sendApiService.postSendAccess.mockRejectedValue(errorResponse); - - await expect(firstValueFrom(service.redirect$(sendId))).rejects.toBe(errorResponse); - expect(logger.warn).toHaveBeenCalledWith( - errorResponse, - "received unexpected error response", - ); - }); - }); - - it("throws error when non-ErrorResponse error occurs", async () => { - const regularError = new Error("Network error"); - sendApiService.postSendAccess.mockRejectedValue(regularError); - - await expect(firstValueFrom(service.redirect$(sendId))).rejects.toThrow("Network error"); - expect(logger.warn).not.toHaveBeenCalled(); - }); - - it("emits timeout error when API response exceeds 10 seconds", fakeAsync(() => { - // Mock API to never resolve (simulating a hung request) - sendApiService.postSendAccess.mockReturnValue(firstValueFrom(NEVER)); - - const result$ = service.redirect$(sendId); - let error: any; - - result$.subscribe({ - error: (err: unknown) => (error = err), - }); - - // Advance time past 10 seconds - tick(10001); - - expect(error).toBeDefined(); - expect(error.name).toBe("TimeoutError"); - })); - }); - - describe("setContext", () => { - it("updates global state with send context when called with sendId and key", async () => { - const sendId = "test-send-id"; - const key = "test-key"; - - await service.setContext(sendId, key); - - const context = await firstValueFrom(stateProvider.getGlobal(SEND_CONTEXT_KEY).state$); - expect(context).toEqual({ id: sendId, key }); - }); - }); - - describe("clear", () => { - it("sets both SEND_RESPONSE_KEY and SEND_CONTEXT_KEY to null when called", async () => { - // Set initial values - await stateProvider.getGlobal(SEND_RESPONSE_KEY).update(() => ({ some: "response" }) as any); - await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: "test", key: "test" })); - - await service.clear(); - - const response = await firstValueFrom(stateProvider.getGlobal(SEND_RESPONSE_KEY).state$); - const context = await firstValueFrom(stateProvider.getGlobal(SEND_CONTEXT_KEY).state$); - - expect(response).toBeNull(); - expect(context).toBeNull(); - }); - }); -}); diff --git a/apps/web/src/app/tools/send/send-access/default-send-access-service.ts b/apps/web/src/app/tools/send/send-access/default-send-access-service.ts deleted file mode 100644 index 732303ce25a..00000000000 --- a/apps/web/src/app/tools/send/send-access/default-send-access-service.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Injectable, Inject } from "@angular/core"; -import { Router, UrlTree } from "@angular/router"; -import { map, of, from, catchError, timeout } from "rxjs"; - -import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; -import { StateProvider } from "@bitwarden/common/platform/state"; -import { SemanticLogger } from "@bitwarden/common/tools/log"; -import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; -import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; -import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components"; - -import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY } from "./send-access-memory"; -import { SendAccessService } from "./send-access-service.abstraction"; -import { isErrorResponse } from "./util"; - -const TEN_SECONDS = 10_000; - -@Injectable({ providedIn: "root" }) -export class DefaultSendAccessService implements SendAccessService { - private readonly logger: SemanticLogger; - - constructor( - private readonly state: StateProvider, - private readonly api: SendApiService, - private readonly router: Router, - @Inject(SYSTEM_SERVICE_PROVIDER) system: SystemServiceProvider, - ) { - this.logger = system.log({ type: "SendAccessAuthenticationService" }); - } - - redirect$(sendId: string) { - // FIXME: when the send authentication APIs become available, this method - // should delegate to the API - const response$ = from(this.api.postSendAccess(sendId, new SendAccessRequest())); - - const redirect$ = response$.pipe( - timeout({ first: TEN_SECONDS }), - map((_response) => { - this.logger.info("public send detected; redirecting to send access with token."); - const url = this.toViewRedirect(sendId); - - return url; - }), - catchError((error: unknown) => { - let processed: UrlTree | undefined = undefined; - - if (isErrorResponse(error)) { - processed = this.toErrorRedirect(sendId, error); - } - - if (processed) { - return of(processed); - } - - throw error; - }), - ); - - return redirect$; - } - - private toViewRedirect(sendId: string) { - return this.router.createUrlTree(["send", "content", sendId]); - } - - private toErrorRedirect(sendId: string, response: ErrorResponse) { - let url: UrlTree | undefined = undefined; - - switch (response.statusCode) { - case 401: - this.logger.debug(response, "redirecting to password flow"); - url = this.router.createUrlTree(["send/password", sendId]); - break; - - case 404: - this.logger.debug(response, "redirecting to unavailable page"); - url = this.router.parseUrl("/404.html"); - break; - - default: - this.logger.warn(response, "received unexpected error response"); - } - - return url; - } - - async setContext(sendId: string, key: string) { - await this.state.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key })); - } - - async clear(): Promise { - await this.state.getGlobal(SEND_RESPONSE_KEY).update(() => null); - await this.state.getGlobal(SEND_CONTEXT_KEY).update(() => null); - } -} diff --git a/apps/web/src/app/tools/send/send-access/index.ts b/apps/web/src/app/tools/send/send-access/index.ts index 4bef65f468b..c9df5ce5193 100644 --- a/apps/web/src/app/tools/send/send-access/index.ts +++ b/apps/web/src/app/tools/send/send-access/index.ts @@ -1,4 +1,2 @@ export { AccessComponent } from "./access.component"; export { SendAccessExplainerComponent } from "./send-access-explainer.component"; - -export { SendAccessRoutes } from "./routes"; diff --git a/apps/web/src/app/tools/send/send-access/routes.ts b/apps/web/src/app/tools/send/send-access/routes.ts deleted file mode 100644 index e94453c9351..00000000000 --- a/apps/web/src/app/tools/send/send-access/routes.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Routes } from "@angular/router"; - -import { ActiveSendIcon } from "@bitwarden/assets/svg"; -import { AnonLayoutWrapperData } from "@bitwarden/components"; - -import { RouteDataProperties } from "../../../core"; - -import { SendAccessExplainerComponent } from "./send-access-explainer.component"; -import { SendAccessPasswordComponent } from "./send-access-password.component"; -import { trySendAccess } from "./try-send-access.guard"; - -/** Routes to reach send access screens */ -export const SendAccessRoutes: Routes = [ - { - path: "send/:sendId", - // there are no child pages because `trySendAccess` always performs a redirect - canActivate: [trySendAccess], - }, - { - path: "send/password/:sendId", - data: { - pageTitle: { - key: "sendAccessPasswordTitle", - }, - pageIcon: ActiveSendIcon, - showReadonlyHostname: true, - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [ - { - path: "", - component: SendAccessPasswordComponent, - }, - { - path: "", - outlet: "secondary", - component: SendAccessExplainerComponent, - }, - ], - }, - { - path: "send/content/:sendId", - data: { - pageTitle: { - key: "sendAccessContentTitle", - }, - pageIcon: ActiveSendIcon, - showReadonlyHostname: true, - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [ - { - path: "send/password/:sendId", - }, - { - path: "", - outlet: "secondary", - component: SendAccessExplainerComponent, - }, - ], - }, -]; diff --git a/apps/web/src/app/tools/send/send-access/send-access-memory.spec.ts b/apps/web/src/app/tools/send/send-access/send-access-memory.spec.ts deleted file mode 100644 index 8d7fe9cd380..00000000000 --- a/apps/web/src/app/tools/send/send-access/send-access-memory.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { KeyDefinition, SEND_ACCESS_AUTH_MEMORY } from "@bitwarden/common/platform/state"; -import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; - -import { SEND_CONTEXT_KEY, SEND_RESPONSE_KEY } from "./send-access-memory"; -import { SendContext } from "./types"; - -describe("send-access-memory", () => { - describe("SEND_CONTEXT_KEY", () => { - it("has correct state definition properties", () => { - expect(SEND_CONTEXT_KEY).toBeInstanceOf(KeyDefinition); - expect(SEND_CONTEXT_KEY.stateDefinition).toBe(SEND_ACCESS_AUTH_MEMORY); - expect(SEND_CONTEXT_KEY.key).toBe("sendContext"); - }); - - it("deserializes data as-is", () => { - const testContext: SendContext = { id: "test-id", key: "test-key" }; - const deserializer = SEND_CONTEXT_KEY.deserializer; - expect(deserializer(testContext)).toBe(testContext); - }); - - it("deserializes null as null", () => { - const deserializer = SEND_CONTEXT_KEY.deserializer; - expect(deserializer(null)).toBe(null); - }); - }); - - describe("SEND_RESPONSE_KEY", () => { - it("has correct state definition properties", () => { - expect(SEND_RESPONSE_KEY).toBeInstanceOf(KeyDefinition); - expect(SEND_RESPONSE_KEY.stateDefinition).toBe(SEND_ACCESS_AUTH_MEMORY); - expect(SEND_RESPONSE_KEY.key).toBe("sendResponse"); - }); - - it("deserializes data into SendAccessResponse instance", () => { - const mockData = { id: "test-id", name: "test-send" } as any; - const deserializer = SEND_RESPONSE_KEY.deserializer; - const result = deserializer(mockData); - - expect(result).toBeInstanceOf(SendAccessResponse); - }); - - it.each([ - [null, "null"], - [undefined, "undefined"], - ])("deserializes %s as null", (value, _) => { - const deserializer = SEND_RESPONSE_KEY.deserializer; - expect(deserializer(value!)).toBe(null); - }); - }); -}); diff --git a/apps/web/src/app/tools/send/send-access/send-access-memory.ts b/apps/web/src/app/tools/send/send-access/send-access-memory.ts deleted file mode 100644 index 4f67cf43b37..00000000000 --- a/apps/web/src/app/tools/send/send-access/send-access-memory.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { KeyDefinition, SEND_ACCESS_AUTH_MEMORY } from "@bitwarden/common/platform/state"; -import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; - -import { SendContext } from "./types"; - -export const SEND_CONTEXT_KEY = new KeyDefinition( - SEND_ACCESS_AUTH_MEMORY, - "sendContext", - { - deserializer: (data) => data, - }, -); - -/** When send authentication succeeds, this stores the result so that - * multiple access attempts don't accrue due to the send workflow. - */ -// FIXME: replace this with the send authentication token once it's -// available -export const SEND_RESPONSE_KEY = new KeyDefinition( - SEND_ACCESS_AUTH_MEMORY, - "sendResponse", - { - deserializer: (data) => (data ? new SendAccessResponse(data) : null), - }, -); diff --git a/apps/web/src/app/tools/send/send-access/send-access-service.abstraction.ts b/apps/web/src/app/tools/send/send-access/send-access-service.abstraction.ts deleted file mode 100644 index 66fc87fe802..00000000000 --- a/apps/web/src/app/tools/send/send-access/send-access-service.abstraction.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { UrlTree } from "@angular/router"; -import { Observable } from "rxjs"; - -export abstract class SendAccessService { - abstract redirect$: (sendId: string) => Observable; - - abstract setContext: (sendId: string, key: string) => Promise; - - abstract clear: () => Promise; -} diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.ts b/apps/web/src/app/tools/send/send-access/send-view.component.ts index 0397575f021..060dc1958b1 100644 --- a/apps/web/src/app/tools/send/send-access/send-view.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-view.component.ts @@ -11,12 +11,12 @@ import { ErrorResponse } from "@bitwarden/common/models/response/error.response" import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; diff --git a/apps/web/src/app/tools/send/send-access/try-send-access.guard.spec.ts b/apps/web/src/app/tools/send/send-access/try-send-access.guard.spec.ts deleted file mode 100644 index 267de83db9f..00000000000 --- a/apps/web/src/app/tools/send/send-access/try-send-access.guard.spec.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { TestBed } from "@angular/core/testing"; -import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from "@angular/router"; -import { firstValueFrom, Observable, of } from "rxjs"; - -import { SemanticLogger } from "@bitwarden/common/tools/log"; -import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; -import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components"; - -import { SendAccessService } from "./send-access-service.abstraction"; -import { trySendAccess } from "./try-send-access.guard"; - -function createMockRoute(params: Record): ActivatedRouteSnapshot { - return { params } as ActivatedRouteSnapshot; -} - -function createMockLogger(): SemanticLogger { - return { - warn: jest.fn(), - panic: jest.fn().mockImplementation(() => { - throw new Error("Logger panic called"); - }), - } as any as SemanticLogger; -} - -function createMockSystemServiceProvider(): SystemServiceProvider { - return { - log: jest.fn().mockReturnValue(createMockLogger()), - } as any as SystemServiceProvider; -} - -function createMockSendAccessService() { - return { - setContext: jest.fn().mockResolvedValue(undefined), - redirect$: jest.fn().mockReturnValue(of({} as UrlTree)), - clear: jest.fn().mockResolvedValue(undefined), - }; -} - -describe("trySendAccess", () => { - let mockSendAccessService: ReturnType; - let mockSystemServiceProvider: SystemServiceProvider; - let mockRouterState: RouterStateSnapshot; - - beforeEach(() => { - mockSendAccessService = createMockSendAccessService(); - mockSystemServiceProvider = createMockSystemServiceProvider(); - mockRouterState = {} as RouterStateSnapshot; - - TestBed.configureTestingModule({ - providers: [ - { provide: SendAccessService, useValue: mockSendAccessService }, - { provide: SYSTEM_SERVICE_PROVIDER, useValue: mockSystemServiceProvider }, - ], - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe("canActivate", () => { - describe("given valid route parameters", () => { - it("extracts sendId and key from route params when both are valid strings", async () => { - const sendId = "test-send-id"; - const key = "test-key"; - const mockRoute = createMockRoute({ sendId, key }); - const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; - mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree)); - - // need to cast the result because `CanActivateFn` performs type erasure - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - - expect(mockSendAccessService.setContext).toHaveBeenCalledWith(sendId, key); - expect(mockSendAccessService.setContext).toHaveBeenCalledTimes(1); - await expect(firstValueFrom(result$)).resolves.toEqual(expectedUrlTree); - }); - - it("does not throw validation errors when sendId and key are valid strings", async () => { - const sendId = "valid-send-id"; - const key = "valid-key"; - const mockRoute = createMockRoute({ sendId, key }); - const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; - mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree)); - - // Should not throw any errors during guard execution - let guardResult: Observable | undefined; - expect(() => { - guardResult = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - }).not.toThrow(); - - // Verify the observable can be subscribed to without errors - expect(guardResult).toBeDefined(); - await expect(firstValueFrom(guardResult!)).resolves.toEqual(expectedUrlTree); - - // Logger methods should not be called for warnings or panics - const mockLogger = (mockSystemServiceProvider.log as jest.Mock).mock.results[0].value; - expect(mockLogger.warn).not.toHaveBeenCalled(); - expect(mockLogger.panic).not.toHaveBeenCalled(); - }); - }); - - describe("given invalid route parameters", () => { - describe("given invalid sendId", () => { - it.each([ - ["undefined", undefined], - ["null", null], - ])( - "logs warning with correct message when sendId is %s", - async (description, sendIdValue) => { - const key = "valid-key"; - const mockRoute = createMockRoute( - sendIdValue === undefined ? { key } : { sendId: sendIdValue, key }, - ); - const mockLogger = createMockLogger(); - (mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger); - - await expect(async () => { - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - await firstValueFrom(result$); - }).rejects.toThrow("Logger panic called"); - - expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ - function: "trySendAccess", - }); - expect(mockLogger.warn).toHaveBeenCalledWith( - "sendId missing from the route parameters; redirecting to 404", - ); - }, - ); - - it.each([ - ["number", 123], - ["object", {}], - ["boolean", true], - ])("logs panic with expected/actual type info when sendId is %s", async (type, value) => { - const key = "valid-key"; - const mockRoute = createMockRoute({ sendId: value, key }); - const mockLogger = createMockLogger(); - (mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger); - - await expect(async () => { - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - await firstValueFrom(result$); - }).rejects.toThrow("Logger panic called"); - - expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ function: "trySendAccess" }); - expect(mockLogger.panic).toHaveBeenCalledWith( - { expected: "string", actual: type }, - "sendId has invalid type", - ); - }); - - it("throws when sendId is not a string", async () => { - const key = "valid-key"; - const invalidSendIdValues = [123, {}, true, null, undefined]; - - for (const invalidSendId of invalidSendIdValues) { - const mockRoute = createMockRoute( - invalidSendId === undefined ? { key } : { sendId: invalidSendId, key }, - ); - const mockLogger = createMockLogger(); - (mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger); - - await expect(async () => { - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - await firstValueFrom(result$); - }).rejects.toThrow("Logger panic called"); - } - }); - }); - - describe("given invalid key", () => { - it.each([ - ["undefined", undefined], - ["null", null], - ])("logs panic with correct message when key is %s", async (description, keyValue) => { - const sendId = "valid-send-id"; - const mockRoute = createMockRoute( - keyValue === undefined ? { sendId } : { sendId, key: keyValue }, - ); - const mockLogger = createMockLogger(); - (mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger); - - await expect(async () => { - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - await firstValueFrom(result$); - }).rejects.toThrow("Logger panic called"); - - expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ function: "trySendAccess" }); - expect(mockLogger.panic).toHaveBeenCalledWith("key missing from the route parameters"); - }); - - it.each([ - ["number", 123], - ["object", {}], - ["boolean", true], - ])("logs panic with expected/actual type info when key is %s", async (type, value) => { - const sendId = "valid-send-id"; - const mockRoute = createMockRoute({ sendId, key: value }); - const mockLogger = createMockLogger(); - (mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger); - - await expect(async () => { - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - await firstValueFrom(result$); - }).rejects.toThrow("Logger panic called"); - - expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ function: "trySendAccess" }); - expect(mockLogger.panic).toHaveBeenCalledWith( - { expected: "string", actual: type }, - "key has invalid type", - ); - }); - - it("throws when key is not a string", async () => { - const sendId = "valid-send-id"; - const invalidKeyValues = [123, {}, true, null, undefined]; - - for (const invalidKey of invalidKeyValues) { - const mockRoute = createMockRoute( - invalidKey === undefined ? { sendId } : { sendId, key: invalidKey }, - ); - const mockLogger = createMockLogger(); - (mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger); - - await expect(async () => { - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - await firstValueFrom(result$); - }).rejects.toThrow("Logger panic called"); - } - }); - }); - }); - - describe("given service interactions", () => { - it("calls setContext with extracted sendId and key when parameters are valid", async () => { - const sendId = "test-send-id"; - const key = "test-key"; - const mockRoute = createMockRoute({ sendId, key }); - const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; - mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree)); - - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - - await firstValueFrom(result$); - - expect(mockSendAccessService.setContext).toHaveBeenCalledWith(sendId, key); - expect(mockSendAccessService.setContext).toHaveBeenCalledTimes(1); - }); - - it("calls redirect$ with extracted sendId when setContext completes", async () => { - const sendId = "test-send-id"; - const key = "test-key"; - const mockRoute = createMockRoute({ sendId, key }); - const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; - mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree)); - - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - - await firstValueFrom(result$); - - expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId); - expect(mockSendAccessService.redirect$).toHaveBeenCalledTimes(1); - }); - }); - - describe("given observable behavior", () => { - it("returns redirect$ emissions when setContext completes successfully", async () => { - const sendId = "test-send-id"; - const key = "test-key"; - const mockRoute = createMockRoute({ sendId, key }); - const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; - mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree)); - - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - - const actualResult = await firstValueFrom(result$); - - expect(actualResult).toEqual(expectedUrlTree); - expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId); - }); - - it("does not emit setContext values when using ignoreElements", async () => { - const sendId = "test-send-id"; - const key = "test-key"; - const mockRoute = createMockRoute({ sendId, key }); - const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; - const setContextValue = "should-not-be-emitted"; - - // Mock setContext to return a value - mockSendAccessService.setContext.mockResolvedValue(setContextValue); - mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree)); - - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - - const actualResult = await firstValueFrom(result$); - - // Should only emit the redirect$ value, not the setContext value - expect(actualResult).toEqual(expectedUrlTree); - expect(actualResult).not.toEqual(setContextValue); - }); - - it("ensures setContext completes before redirect$ executes (sequencing)", async () => { - const sendId = "test-send-id"; - const key = "test-key"; - const mockRoute = createMockRoute({ sendId, key }); - const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; - - let setContextResolved = false; - - // Mock setContext to track when it resolves - mockSendAccessService.setContext.mockImplementation(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay - setContextResolved = true; - }); - - // Mock redirect$ to return a delayed observable and check if setContext resolved - mockSendAccessService.redirect$.mockImplementation((id) => { - return new Observable((subscriber) => { - // Check if setContext has resolved when redirect$ subscription starts - setTimeout(() => { - expect(setContextResolved).toBe(true); - subscriber.next(expectedUrlTree); - subscriber.complete(); - }, 0); - }); - }); - - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - - await firstValueFrom(result$); - }); - }); - - describe("given error scenarios", () => { - it("does not call redirect$ when setContext rejects", async () => { - const sendId = "test-send-id"; - const key = "test-key"; - const mockRoute = createMockRoute({ sendId, key }); - const setContextError = new Error("setContext failed"); - - // Reset mocks to ensure clean state - jest.clearAllMocks(); - - // Mock setContext to reject - mockSendAccessService.setContext.mockRejectedValue(setContextError); - - // Create a mock observable that we can spy on subscription - const mockRedirectObservable = of({} as UrlTree); - const subscribeSpy = jest.spyOn(mockRedirectObservable, "subscribe"); - mockSendAccessService.redirect$.mockReturnValue(mockRedirectObservable); - - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - - // Expect the observable to reject when setContext fails - await expect(firstValueFrom(result$)).rejects.toThrow("setContext failed"); - - // The redirect$ method will be called (since it's called synchronously) - expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId); - - // But the returned observable should not be subscribed to due to the error - // Note: This test verifies the error propagation behavior - expect(subscribeSpy).not.toHaveBeenCalled(); - }); - - it("propagates error to guard return value when redirect$ throws", async () => { - const sendId = "test-send-id"; - const key = "test-key"; - const mockRoute = createMockRoute({ sendId, key }); - const redirectError = new Error("redirect$ failed"); - - // Reset mocks to ensure clean state - jest.clearAllMocks(); - - // Mock setContext to succeed and redirect$ to throw - mockSendAccessService.setContext.mockResolvedValue(undefined); - mockSendAccessService.redirect$.mockReturnValue( - new Observable((subscriber) => { - subscriber.error(redirectError); - }), - ); - - const result$ = TestBed.runInInjectionContext(() => - trySendAccess(mockRoute, mockRouterState), - ) as unknown as Observable; - - // Expect the observable to propagate the redirect$ error - await expect(firstValueFrom(result$)).rejects.toThrow("redirect$ failed"); - - // Verify that setContext was called (should succeed) - expect(mockSendAccessService.setContext).toHaveBeenCalledWith(sendId, key); - - // Verify that redirect$ was called (but it throws) - expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId); - }); - }); - }); -}); diff --git a/apps/web/src/app/tools/send/send-access/try-send-access.guard.ts b/apps/web/src/app/tools/send/send-access/try-send-access.guard.ts deleted file mode 100644 index 51941bf8e74..00000000000 --- a/apps/web/src/app/tools/send/send-access/try-send-access.guard.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { inject } from "@angular/core"; -import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router"; -import { from, ignoreElements, concat } from "rxjs"; - -import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; -import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components"; - -import { SendAccessService } from "./send-access-service.abstraction"; - -export const trySendAccess: CanActivateFn = ( - route: ActivatedRouteSnapshot, - _state: RouterStateSnapshot, -) => { - const sendAccess = inject(SendAccessService); - const system = inject(SYSTEM_SERVICE_PROVIDER); - const logger = system.log({ function: "trySendAccess" }); - - const { sendId, key } = route.params; - if (!sendId) { - logger.warn("sendId missing from the route parameters; redirecting to 404"); - } - if (typeof sendId !== "string") { - logger.panic({ expected: "string", actual: typeof sendId }, "sendId has invalid type"); - } - - if (!key) { - logger.panic("key missing from the route parameters"); - } - if (typeof key !== "string") { - logger.panic({ expected: "string", actual: typeof key }, "key has invalid type"); - } - - const contextUpdated$ = from(sendAccess.setContext(sendId, key)).pipe(ignoreElements()); - const redirect$ = sendAccess.redirect$(sendId); - - // ensure the key has loaded before redirecting - return concat(contextUpdated$, redirect$); -}; diff --git a/apps/web/src/app/tools/send/send-access/types.ts b/apps/web/src/app/tools/send/send-access/types.ts deleted file mode 100644 index 03e058ca681..00000000000 --- a/apps/web/src/app/tools/send/send-access/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** global contextual information for the current send access page. */ -export type SendContext = { - /** identifies the send */ - id: string; - - /** decrypts the send content */ - key: string; -}; diff --git a/apps/web/src/app/tools/send/send-access/util.spec.ts b/apps/web/src/app/tools/send/send-access/util.spec.ts deleted file mode 100644 index 45502ee2509..00000000000 --- a/apps/web/src/app/tools/send/send-access/util.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; - -import { isErrorResponse, isSendContext } from "./util"; - -describe("util", () => { - describe("isErrorResponse", () => { - it("returns true when value is an ErrorResponse instance", () => { - const error = new ErrorResponse(["Error message"], 400); - expect(isErrorResponse(error)).toBe(true); - }); - - it.each([ - [null, "null"], - [undefined, "undefined"], - ])("returns false when value is %s", (value, description) => { - expect(isErrorResponse(value)).toBe(false); - }); - - it.each([ - ["string", "string"], - [123, "number"], - [true, "boolean"], - [{}, "plain object"], - [[], "array"], - ])("returns false when value is not an ErrorResponse (%s)", (value, description) => { - expect(isErrorResponse(value)).toBe(false); - }); - - it("returns false when value is a different Error type", () => { - const error = new Error("test"); - expect(isErrorResponse(error)).toBe(false); - }); - }); - - describe("isSendContext", () => { - it("returns true when value has id and key properties", () => { - const validContext = { id: "test-id", key: "test-key" }; - expect(isSendContext(validContext)).toBe(true); - }); - - it("returns true even with additional properties", () => { - const contextWithExtras = { id: "test-id", key: "test-key", extra: "data" }; - expect(isSendContext(contextWithExtras)).toBe(true); - }); - - it.each([ - [null, "null"], - [undefined, "undefined"], - ])("returns false when value is %s", (value, _) => { - expect(isSendContext(value)).toBe(false); - }); - - it.each([ - ["string", "string"], - [123, "number"], - [true, "boolean"], - ])("returns false when value is not an object (%s)", (value, _) => { - expect(isSendContext(value)).toBe(false); - }); - - it.each([ - [{ key: "test-key" }, "missing id"], - [{ id: "test-id" }, "missing key"], - [{}, "empty object"], - ])("returns false when value is %s", (value, _) => { - expect(isSendContext(value)).toBe(false); - }); - }); -}); diff --git a/apps/web/src/app/tools/send/send-access/util.ts b/apps/web/src/app/tools/send/send-access/util.ts deleted file mode 100644 index d9cbef0d337..00000000000 --- a/apps/web/src/app/tools/send/send-access/util.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; - -import { SendContext } from "./types"; - -/** narrows a type to an `ErrorResponse` */ -export function isErrorResponse(value: unknown): value is ErrorResponse { - return value instanceof ErrorResponse; -} - -/** narrows a type to a `SendContext` */ -export function isSendContext(value: unknown): value is SendContext { - return !!value && typeof value === "object" && "id" in value && "key" in value; -} diff --git a/apps/web/src/app/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index 6418744a727..a40cb3d4330 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -17,100 +17,159 @@ {{ "sendDisabledWarning" | i18n }} -
    -
    -
    -
    - {{ "filters" | i18n }} -
    -
    -
    - -
    -
    -
      -
    • - - - -
    • -
    -
    -
    -
    -

    {{ "types" | i18n }}

    -
    -
      -
    • - - - -
    • -
    • - - - -
    • -
    -
    -
    -
    -
    -
    - -
    - - - {{ "loading" | i18n }} - - - - {{ "sendsTitleNoItems" | i18n }} - {{ "sendsBodyNoItems" | i18n }} - - - +@if (SendUIRefresh$ | async) { +
    + +
    + +
    + + {{ "allSends" | i18n }} + {{ "sendTypeText" | i18n }} + {{ "sendTypeFile" | i18n }} + +
    + +
    +
    + + +
    + + + {{ "loading" | i18n }} + + + + {{ "sendsTitleNoItems" | i18n }} + {{ "sendsBodyNoItems" | i18n }} + + + +
    -
    +} @else { +
    +
    +
    +
    + {{ "filters" | i18n }} +
    +
    +
    + +
    +
    +
      +
    • + + + +
    • +
    +
    +
    +
    +

    {{ "types" | i18n }}

    +
    +
      +
    • + + + +
    • +
    • + + + +
    • +
    +
    +
    +
    +
    +
    + + +
    + + + {{ "loading" | i18n }} + + + + {{ "sendsTitleNoItems" | i18n }} + {{ "sendsBodyNoItems" | i18n }} + + + +
    +
    +
    +} diff --git a/apps/web/src/app/tools/send/send.component.ts b/apps/web/src/app/tools/send/send.component.ts index 7c0e03e3e21..db45b104900 100644 --- a/apps/web/src/app/tools/send/send.component.ts +++ b/apps/web/src/app/tools/send/send.component.ts @@ -1,7 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, NgZone, OnInit, OnDestroy } from "@angular/core"; -import { lastValueFrom } from "rxjs"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { lastValueFrom, Observable, switchMap, EMPTY } from "rxjs"; import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component"; import { NoSendsIcon } from "@bitwarden/assets/svg"; @@ -17,6 +19,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendFilterType } from "@bitwarden/common/tools/send/types/send-filter-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { @@ -26,6 +30,7 @@ import { SearchModule, TableDataSource, ToastService, + ToggleGroupModule, } from "@bitwarden/components"; import { DefaultSendFormConfigService, @@ -39,6 +44,7 @@ import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; import { NewSendDropdownComponent } from "./new-send/new-send-dropdown.component"; +import { SendSuccessDrawerDialogComponent } from "./shared"; const BroadcasterSubscriptionId = "SendComponent"; @@ -52,6 +58,7 @@ const BroadcasterSubscriptionId = "SendComponent"; NoItemsModule, HeaderModule, NewSendDropdownComponent, + ToggleGroupModule, SendTableComponent, ], templateUrl: "send.component.html", @@ -60,6 +67,8 @@ const BroadcasterSubscriptionId = "SendComponent"; export class SendComponent extends BaseSendComponent implements OnInit, OnDestroy { private sendItemDialogRef?: DialogRef | undefined; noItemIcon = NoSendsIcon; + selectedToggleValue?: SendFilterType; + SendUIRefresh$: Observable; override set filteredSends(filteredSends: SendView[]) { super.filteredSends = filteredSends; @@ -87,6 +96,8 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro toastService: ToastService, private addEditFormConfigService: DefaultSendFormConfigService, accountService: AccountService, + private route: ActivatedRoute, + private router: Router, private configService: ConfigService, ) { super( @@ -103,10 +114,38 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro toastService, accountService, ); + + this.SendUIRefresh$ = this.configService.getFeatureFlag$(FeatureFlag.SendUIRefresh); + + this.SendUIRefresh$.pipe( + switchMap((sendUiRefreshEnabled) => { + if (sendUiRefreshEnabled) { + return this.route.queryParamMap; + } + return EMPTY; + }), + takeUntilDestroyed(), + ).subscribe((params) => { + const typeParam = params.get("type"); + const value = ( + typeParam === SendFilterType.Text || typeParam === SendFilterType.File + ? typeParam + : SendFilterType.All + ) as SendFilterType; + this.selectedToggleValue = value; + + if (this.loaded) { + this.applyTypeFilter(value); + } + }); } async ngOnInit() { await super.ngOnInit(); + this.onSuccessfulLoad = async () => { + this.applyTypeFilter(this.selectedToggleValue); + }; + await this.load(); // Broadcaster subscription - load if sync completes in the background @@ -172,12 +211,49 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro }); } - const result = await lastValueFrom(this.sendItemDialogRef.closed); + const result: SendItemDialogResult = await lastValueFrom(this.sendItemDialogRef.closed); this.sendItemDialogRef = undefined; // If the dialog was closed by deleting the cipher, refresh the vault. - if (result === SendItemDialogResult.Deleted || result === SendItemDialogResult.Saved) { + if ( + result?.result === SendItemDialogResult.Deleted || + result?.result === SendItemDialogResult.Saved + ) { await this.load(); } + + if ( + result?.result === SendItemDialogResult.Saved && + result?.send && + (await this.configService.getFeatureFlag(FeatureFlag.SendUIRefresh)) + ) { + this.dialogService.openDrawer(SendSuccessDrawerDialogComponent, { + data: result.send, + }); + } + } + + private applyTypeFilter(value: SendFilterType) { + if (value === SendFilterType.All) { + this.selectAll(); + } else if (value === SendFilterType.Text) { + this.selectType(SendType.Text); + } else if (value === SendFilterType.File) { + this.selectType(SendType.File); + } + } + + onToggleChange(value: SendFilterType) { + const queryParams = value === SendFilterType.All ? { type: null } : { type: value }; + + this.router + .navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: "merge", + }) + .catch((err) => { + this.logService.error("Failed to update route query params:", err); + }); } } diff --git a/apps/web/src/app/tools/send/shared/index.ts b/apps/web/src/app/tools/send/shared/index.ts new file mode 100644 index 00000000000..afc507ee464 --- /dev/null +++ b/apps/web/src/app/tools/send/shared/index.ts @@ -0,0 +1 @@ +export { SendSuccessDrawerDialogComponent } from "./send-success-drawer-dialog.component"; 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 new file mode 100644 index 00000000000..b9326ca08ac --- /dev/null +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html @@ -0,0 +1,45 @@ + + + {{ dialogTitle() | i18n }} + + +
    +
    +
    + +
    +
    + +

    + {{ "sendCreatedSuccessfully" | i18n }} +

    + +

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

    + + + {{ "sendLink" | i18n }} + + + +
    +
    + + + + + +
    diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts new file mode 100644 index 00000000000..67e01cd9ff0 --- /dev/null +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts @@ -0,0 +1,75 @@ +import { Component, ChangeDetectionStrategy, Inject, signal, computed } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { ActiveSendIcon } from "@bitwarden/assets/svg"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { DIALOG_DATA, DialogModule, ToastService, TypographyModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +@Component({ + imports: [SharedModule, DialogModule, TypographyModule], + templateUrl: "./send-success-drawer-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendSuccessDrawerDialogComponent { + readonly sendLink = signal(""); + activeSendIcon = ActiveSendIcon; + + // Computed property to get the dialog title based on send type + readonly dialogTitle = computed(() => { + return this.send.type === SendType.Text ? "newTextSend" : "newFileSend"; + }); + + constructor( + @Inject(DIALOG_DATA) public send: SendView, + private environmentService: EnvironmentService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, + ) { + void this.initLink(); + } + + async initLink() { + const env = await firstValueFrom(this.environmentService.environment$); + this.sendLink.set(env.getSendUrl() + this.send.accessId + "/" + this.send.urlB64Key); + } + + get formattedExpirationTime(): string { + if (!this.send.deletionDate) { + return ""; + } + const hoursAvailable = this.getHoursAvailable(this.send); + if (hoursAvailable < 24) { + return hoursAvailable === 1 + ? this.i18nService.t("oneHour").toLowerCase() + : this.i18nService.t("durationTimeHours", String(hoursAvailable)).toLowerCase(); + } + const daysAvailable = Math.ceil(hoursAvailable / 24); + return daysAvailable === 1 + ? this.i18nService.t("oneDay").toLowerCase() + : this.i18nService.t("days", String(daysAvailable)).toLowerCase(); + } + + private getHoursAvailable(send: SendView): number { + const now = new Date().getTime(); + const deletionDate = new Date(send.deletionDate).getTime(); + return Math.max(0, Math.ceil((deletionDate - now) / (1000 * 60 * 60))); + } + + copyLink() { + const link = this.sendLink(); + if (!link) { + return; + } + this.platformUtilsService.copyToClipboard(link); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("valueCopied", this.i18nService.t("sendLink")), + }); + } +} 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 16256ab875a..e4030a7ab18 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 @@ -2,7 +2,8 @@ {{ title }} - @if (cipherIsArchived) { + + @if (isCipherArchived) { {{ "archived" | i18n }} } @@ -83,17 +84,39 @@ } - @if (showDelete) { + @if (showActionButtons) {
    - + @if ((userCanArchive$ | async) && !params.isAdminConsoleAction) { + @if (isCipherArchived) { + + } + @if (cipher?.canBeArchived) { + + } + } + @if (cipher) { + + }
    } diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts index 11862b569fc..63b5071d1f5 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts @@ -1,25 +1,34 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { provideNoopAnimations } from "@angular/platform-browser/animations"; import { ActivatedRoute, Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.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"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { TaskService } from "@bitwarden/common/vault/tasks"; import { DialogRef, DIALOG_DATA, DialogService, ToastService } from "@bitwarden/components"; - -import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; +import { RoutedVaultFilterService } from "@bitwarden/vault"; import { VaultItemDialogComponent } from "./vault-item-dialog.component"; @@ -33,7 +42,13 @@ class TestVaultItemDialogComponent extends VaultItemDialogComponent { this.params = params; } setTestCipher(cipher: any) { - this.cipher = cipher; + this.cipher = { + ...cipher, + login: { + uris: [], + }, + card: {}, + }; } setTestFormConfig(formConfig: any) { this.formConfig = formConfig; @@ -72,12 +87,23 @@ describe("VaultItemDialogComponent", () => { { provide: DIALOG_DATA, useValue: { ...baseParams } }, { provide: DialogRef, useValue: {} }, { provide: DialogService, useValue: {} }, - { provide: ToastService, useValue: {} }, + { + provide: ToastService, + useValue: { + showToast: () => {}, + }, + }, { provide: MessagingService, useValue: {} }, { provide: LogService, useValue: {} }, { provide: CipherService, useValue: {} }, - { provide: AccountService, useValue: { activeAccount$: { pipe: () => ({}) } } }, - { provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false) } }, + { provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } }, + { + provide: ConfigService, + useValue: { + getFeatureFlag: () => Promise.resolve(false), + getFeatureFlag$: () => of(false), + }, + }, { provide: Router, useValue: {} }, { provide: ActivatedRoute, useValue: {} }, { @@ -89,8 +115,63 @@ describe("VaultItemDialogComponent", () => { { provide: ApiService, useValue: {} }, { provide: EventCollectionService, useValue: {} }, { provide: RoutedVaultFilterService, useValue: {} }, + { + provide: CipherArchiveService, + useValue: { + userCanArchive$: jest.fn().mockReturnValue(of(true)), + hasArchiveFlagEnabled$: jest.fn().mockReturnValue(of(true)), + archiveWithServer: jest.fn().mockResolvedValue({}), + unarchiveWithServer: jest.fn().mockResolvedValue({}), + }, + }, + { + provide: OrganizationService, + useValue: mock(), + }, + { + provide: CollectionService, + useValue: mock(), + }, + { + provide: FolderService, + useValue: mock(), + }, + { + provide: TaskService, + useValue: mock(), + }, + { + provide: ApiService, + useValue: mock(), + }, + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getIconsUrl: () => "https://example.com", + }), + }, + }, + { + provide: DomainSettingsService, + useValue: { + showFavicons$: of(true), + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)), + }, + }, + { + provide: PlatformUtilsService, + useValue: { + getClientType: jest.fn().mockReturnValue("Web"), + }, + }, { provide: SyncService, useValue: {} }, - { provide: PlatformUtilsService, useValue: {} }, + { provide: CipherRiskService, useValue: {} }, ], }).compileComponents(); @@ -140,10 +221,93 @@ describe("VaultItemDialogComponent", () => { expect(component.getTestTitle()).toBe("newItemHeaderCard"); }); }); + + describe("archive", () => { + it("calls archiveService to archive the cipher", async () => { + const archiveService = TestBed.inject(CipherArchiveService); + component.setTestCipher({ id: "111-222-333-4444" }); + component.setTestParams({ mode: "view" }); + fixture.detectChanges(); + + await component.archive(); + + expect(archiveService.archiveWithServer).toHaveBeenCalledWith("111-222-333-4444", "UserId"); + }); + }); + + describe("unarchive", () => { + it("calls archiveService to unarchive the cipher", async () => { + const archiveService = TestBed.inject(CipherArchiveService); + component.setTestCipher({ id: "111-222-333-4444" }); + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + + await component.unarchive(); + + expect(archiveService.unarchiveWithServer).toHaveBeenCalledWith("111-222-333-4444", "UserId"); + }); + }); + + describe("archive button", () => { + it("should not show archive button in admin console", () => { + (component as any).userCanArchive$ = of(true); + component.setTestCipher({ canBeArchived: true }); + component.setTestParams({ mode: "form", isAdminConsoleAction: true }); + fixture.detectChanges(); + const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']")); + expect(archiveButton).toBeFalsy(); + }); + + it("should show archive button when the user can archive the item and the item can be archived", () => { + component.setTestCipher({ canBeArchived: true }); + (component as any).userCanArchive$ = of(true); + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']")); + expect(archiveButton).toBeTruthy(); + }); + + it("should not show archive button when the user cannot archive the item", () => { + (component as any).userCanArchive$ = of(false); + component.setTestCipher({}); + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']")); + expect(archiveButton).toBeFalsy(); + }); + + it("should not show archive button when the item cannot be archived", () => { + component.setTestCipher({ canBeArchived: false }); + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + const archiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-archive']")); + expect(archiveButton).toBeFalsy(); + }); + }); + + describe("unarchive button", () => { + it("should show the unarchive button when the item is archived", () => { + component.setTestCipher({ isArchived: true }); + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + const unarchiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-unarchive']")); + expect(unarchiveButton).toBeTruthy(); + }); + + it("should not show the unarchive button when the item is not archived", () => { + component.setTestCipher({ isArchived: false }); + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + const unarchiveButton = fixture.debugElement.query(By.css("[biticonbutton='bwi-unarchive']")); + expect(unarchiveButton).toBeFalsy(); + }); + }); + describe("submitButtonText$", () => { it("should return 'unArchiveAndSave' when premium is false and cipher is archived", (done) => { jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false)); - component["cipherIsArchived"] = true; + component.setTestCipher({ isArchived: true }); + fixture.detectChanges(); component["submitButtonText$"].subscribe((text) => { expect(text).toBe("unArchiveAndSave"); @@ -153,7 +317,8 @@ describe("VaultItemDialogComponent", () => { it("should return 'save' when cipher is archived and user has premium", (done) => { jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(true)); - component["cipherIsArchived"] = true; + component.setTestCipher({ isArchived: true }); + fixture.detectChanges(); component["submitButtonText$"].subscribe((text) => { expect(text).toBe("save"); @@ -163,7 +328,8 @@ describe("VaultItemDialogComponent", () => { it("should return 'save' when cipher is not archived", (done) => { jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false)); - component["cipherIsArchived"] = false; + component.setTestCipher({ isArchived: false }); + fixture.detectChanges(); component["submitButtonText$"].subscribe((text) => { expect(text).toBe("save"); diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 15e62eaf93e..5d5e319c8af 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -15,11 +15,11 @@ import { Router } from "@angular/router"; import { firstValueFrom, Observable, Subject, switchMap } from "rxjs"; import { map } from "rxjs/operators"; -import { CollectionView } from "@bitwarden/admin-console/common"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -29,6 +29,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; @@ -62,11 +63,11 @@ import { CipherViewComponent, DecryptionFailureDialogComponent, DefaultChangeLoginPasswordService, + RoutedVaultFilterService, + RoutedVaultFilterModel, } from "@bitwarden/vault"; import { SharedModule } from "../../../shared/shared.module"; -import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; -import { RoutedVaultFilterModel } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model"; import { WebCipherFormGenerationService } from "../../services/web-cipher-form-generation.service"; import { WebVaultPremiumUpgradePromptService } from "../../services/web-premium-upgrade-prompt.service"; @@ -231,6 +232,18 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { ), ); + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; + + protected userId$ = this.accountService.activeAccount$.pipe(getUserId); + + /** + * Flag to indicate if the user can archive items. + * @protected + */ + protected userCanArchive$ = this.userId$.pipe( + switchMap((userId) => this.archiveService.userCanArchive$(userId)), + ); + protected get isTrashFilter() { return this.filter?.type === "trash"; } @@ -243,6 +256,10 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return this.isTrashFilter && !this.showRestore; } + protected get showActionButtons() { + return this.cipher !== null && this.formConfig.mode !== "clone"; + } + /** * Determines if the user may restore the item. * A user may restore items if they have delete permissions and the item is in the trash. @@ -253,8 +270,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { protected showRestore: boolean; - protected cipherIsArchived: boolean = false; - protected get loadingForm() { return this.loadForm && !this.formReady; } @@ -267,15 +282,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return this.showCipherView && !this.isTrashFilter && !this.showRestore; } - protected get showDelete() { - // Don't show the delete button when cloning a cipher - if (this.params.mode == "form" && this.formConfig.mode === "clone") { - return false; - } - // Never show the delete button for new ciphers - return this.cipher != null; - } - protected get showCipherView() { return this.cipher != undefined && (this.params.mode === "view" || this.loadingForm); } @@ -283,13 +289,17 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { protected get submitButtonText$(): Observable { return this.userHasPremium$.pipe( map((hasPremium) => - this.cipherIsArchived && !hasPremium + this.isCipherArchived && !hasPremium ? this.i18nService.t("unArchiveAndSave") : this.i18nService.t("save"), ), ); } + protected get isCipherArchived() { + return this.cipher?.isArchived; + } + /** * Flag to initialize/attach the form component. */ @@ -327,6 +337,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { private apiService: ApiService, private eventCollectionService: EventCollectionService, private routedVaultFilterService: RoutedVaultFilterService, + private archiveService: CipherArchiveService, ) { this.updateTitle(); this.premiumUpgradeService.upgradeConfirmed$ @@ -339,7 +350,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { async ngOnInit() { this.cipher = await this.getDecryptedCipherView(this.formConfig); - if (this.cipher) { if (this.cipher.decryptionFailure) { this.dialogService.open(DecryptionFailureDialogComponent, { @@ -350,8 +360,6 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return; } - this.cipherIsArchived = this.cipher.isArchived; - this.collections = this.formConfig.collections.filter((c) => this.cipher.collectionIds?.includes(c.id), ); @@ -406,15 +414,12 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { cipherView.collectionIds?.includes(c.id), ); - // Track cipher archive state for btn text and badge updates - this.cipherIsArchived = this.cipher.isArchived; - // If the cipher was newly created (via add/clone), switch the form to edit for subsequent edits. if (this._originalFormMode === "add" || this._originalFormMode === "clone") { this.formConfig.mode = "edit"; this.formConfig.initialValues = null; } - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const activeUserId = await firstValueFrom(this.userId$); let cipher = await this.cipherService.get(cipherView.id, activeUserId); // When the form config is used within the Admin Console, retrieve the cipher from the admin endpoint (if not found in local state) @@ -508,9 +513,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { result.action === AttachmentDialogResult.Removed || result.action === AttachmentDialogResult.Uploaded ) { - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + const activeUserId = await firstValueFrom(this.userId$); let updatedCipherView: CipherView; @@ -562,11 +565,72 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { await this.changeMode("view"); }; + updateCipherFromArchive = (revisionDate: Date, archivedDate: Date | null) => { + this.cipher.archivedDate = archivedDate; + this.cipher.revisionDate = revisionDate; + + // If we're in View mode, we don't need to update the form. + if (this.params.mode === "view") { + return; + } + + this.cipherFormComponent.patchCipher((current) => { + current.revisionDate = revisionDate; + current.archivedDate = archivedDate; + return current; + }); + }; + + archive = async () => { + const activeUserId = await firstValueFrom(this.userId$); + try { + const cipherResponse = await this.archiveService.archiveWithServer( + this.cipher.id as CipherId, + activeUserId, + ); + this.updateCipherFromArchive( + new Date(cipherResponse.revisionDate), + cipherResponse.archivedDate ? new Date(cipherResponse.archivedDate) : null, + ); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemsWereSentToArchive"), + }); + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + } + }; + + unarchive = async () => { + const activeUserId = await firstValueFrom(this.userId$); + try { + const cipherResponse = await this.archiveService.unarchiveWithServer( + this.cipher.id as CipherId, + activeUserId, + ); + this.updateCipherFromArchive(new Date(cipherResponse.revisionDate), null); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasUnarchived"), + }); + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + return; + } + }; + private async getDecryptedCipherView(config: CipherFormConfig) { if (config.originalCipher == null) { return; } - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const activeUserId = await firstValueFrom(this.userId$); return await this.cipherService.decrypt(config.originalCipher, activeUserId); } diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts index 9378ee54e51..49c9df8d582 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts @@ -142,4 +142,45 @@ describe("VaultCipherRowComponent", () => { expect(overlayContent).not.toContain('appcopyfield="password"'); }); }); + + describe("showAssignToCollections", () => { + let archivedCipher: CipherView; + + beforeEach(() => { + archivedCipher = new CipherView(); + archivedCipher.id = "cipher-1"; + archivedCipher.name = "Test Cipher"; + archivedCipher.type = CipherType.Login; + archivedCipher.organizationId = "org-1"; + archivedCipher.deletedDate = null; + archivedCipher.archivedDate = new Date(); + + component.cipher = archivedCipher; + component.organizations = [{ id: "org-1" } as any]; + component.canAssignCollections = true; + component.disabled = false; + }); + + it("returns true when cipher is archived and conditions are met", () => { + expect(component["showAssignToCollections"]).toBe(true); + }); + + it("returns false when cipher is deleted", () => { + archivedCipher.deletedDate = new Date(); + + expect(component["showAssignToCollections"]).toBe(false); + }); + + it("returns false when user cannot assign collections", () => { + component.canAssignCollections = false; + + expect(component["showAssignToCollections"]).toBe(false); + }); + + it("returns false when there are no organizations", () => { + component.organizations = []; + + expect(component["showAssignToCollections"]).toBeFalsy(); + }); + }); }); diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index e437537b1cc..ec0fe42f927 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -11,7 +11,7 @@ import { input, } from "@angular/core"; -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -217,11 +217,7 @@ export class VaultCipherRowComponent implements OnInit return CipherViewLikeUtils.decryptionFailure(this.cipher); } - // Do Not show Assign to Collections option if item is archived protected get showAssignToCollections() { - if (CipherViewLikeUtils.isArchived(this.cipher)) { - return false; - } return ( this.organizations?.length && this.canAssignCollections && diff --git a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts index daa981d509a..9e6a589849f 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-collection-row.component.ts @@ -7,7 +7,7 @@ import { Unassigned, CollectionView, CollectionTypes, -} from "@bitwarden/admin-console/common"; +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; diff --git a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts index a4651da69e2..8cd4b98af40 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts @@ -1,4 +1,4 @@ -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { CollectionPermission } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/access-selector"; diff --git a/apps/web/src/app/vault/components/vault-items/vault-item.ts b/apps/web/src/app/vault/components/vault-items/vault-item.ts index bccb84fb0bf..27abca09eca 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-item.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-item.ts @@ -1,4 +1,4 @@ -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; export interface VaultItem { diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts index c1c25c625da..ac74e75f07c 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts @@ -2,7 +2,7 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; import { TestBed } from "@angular/core/testing"; import { of, Subject } from "rxjs"; -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -11,8 +11,7 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { MenuModule, TableModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -import { RoutedVaultFilterService } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service"; -import { RoutedVaultFilterModel } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model"; +import { RoutedVaultFilterService, RoutedVaultFilterModel } from "@bitwarden/vault"; import { VaultItem } from "./vault-item"; import { VaultItemsComponent } from "./vault-items.component"; @@ -79,6 +78,101 @@ describe("VaultItemsComponent", () => { component = fixture.componentInstance; }); + describe("bulkArchiveAllowed", () => { + it("returns false when no items are selected", () => { + component.userCanArchive = true; + component["selection"].clear(); + + expect(component.bulkArchiveAllowed).toBe(false); + }); + + it("returns false when userCanArchive is false", () => { + component.userCanArchive = false; + + const items: VaultItem[] = [ + { cipher: cipher1 as CipherView }, + { cipher: cipher2 as CipherView }, + ]; + + component["selection"].select(...items); + + expect(component.bulkArchiveAllowed).toBe(false); + }); + + it("returns false when selecting collections", () => { + component.userCanArchive = true; + const collection1 = { id: "col-1", name: "Collection 1" } as CollectionView; + + const items: VaultItem[] = [ + { cipher: cipher1 as CipherView }, + { collection: collection1 }, + ]; + + component["selection"].select(...items); + + expect(component.bulkArchiveAllowed).toBe(false); + }); + + it("returns true when selecting unarchived ciphers without organization", () => { + component.userCanArchive = true; + + const items: VaultItem[] = [ + { cipher: cipher1 as CipherView }, + { cipher: cipher2 as CipherView }, + ]; + + component["selection"].select(...items); + + expect(component.bulkArchiveAllowed).toBe(true); + }); + + it("returns false when any selected cipher has an organizationId", () => { + component.userCanArchive = true; + + const personalCipher: Partial = { + ...cipher1, + organizationId: undefined, + }; + + const orgCipher: Partial = { + ...cipher2, + organizationId: "org-1", + }; + + const items: VaultItem[] = [ + { cipher: personalCipher as CipherView }, + { cipher: orgCipher as CipherView }, + ]; + + component["selection"].select(...items); + + expect(component.bulkArchiveAllowed).toBe(false); + }); + + it("returns false when any selected cipher is already archived", () => { + component.userCanArchive = true; + + const unarchivedCipher: Partial = { + ...cipher1, + archivedDate: undefined, + }; + + const archivedCipher: Partial = { + ...cipher2, + archivedDate: new Date("2024-01-01"), + }; + + const items: VaultItem[] = [ + { cipher: unarchivedCipher as CipherView }, + { cipher: archivedCipher as CipherView }, + ]; + + component["selection"].select(...items); + + expect(component.bulkArchiveAllowed).toBe(false); + }); + }); + describe("bulkUnarchiveAllowed", () => { it("returns false when no items are selected", () => { component["selection"].clear(); diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index a51009a1e5b..7deaa2ff75e 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -13,7 +13,11 @@ import { switchMap, } from "rxjs"; -import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common"; +import { + CollectionAdminView, + Unassigned, + CollectionView, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; @@ -27,7 +31,7 @@ import { } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { SortDirection, TableDataSource } from "@bitwarden/components"; import { OrganizationId } from "@bitwarden/sdk-internal"; -import { RoutedVaultFilterService } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service"; +import { RoutedVaultFilterService } from "@bitwarden/vault"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -272,7 +276,8 @@ export class VaultItemsComponent { } get bulkArchiveAllowed() { - if (this.selection.selected.length === 0 || !this.userCanArchive) { + const hasCollectionsSelected = this.selection.selected.some((item) => item.collection); + if (this.selection.selected.length === 0 || !this.userCanArchive || hasCollectionsSelected) { return false; } diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts index 9c56df0db59..76d6b460df0 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.stories.ts @@ -11,13 +11,13 @@ import { } from "@storybook/angular"; import { BehaviorSubject, of } from "rxjs"; +import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; +import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; import { CollectionAccessSelectionView, CollectionAdminView, Unassigned, -} from "@bitwarden/admin-console/common"; -import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; -import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; @@ -41,7 +41,7 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { LayoutComponent, StorybookGlobalStateProvider } from "@bitwarden/components"; import { GlobalStateProvider } from "@bitwarden/state"; -import { RoutedVaultFilterService } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service"; +import { RoutedVaultFilterService } from "@bitwarden/vault"; import { GroupView } from "../../../admin-console/organizations/core"; import { PreloadedEnglishI18nModule } from "../../../core/tests"; diff --git a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts index 5f139ade144..46f2b5da735 100644 --- a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts +++ b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts @@ -3,8 +3,9 @@ import { Component, Inject } from "@angular/core"; import { firstValueFrom } from "rxjs"; -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; diff --git a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.ts b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.ts index 19c462193e1..046def13d1a 100644 --- a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.ts +++ b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.ts @@ -3,7 +3,7 @@ import { Component, Input, OnChanges } from "@angular/core"; import { firstValueFrom } from "rxjs"; -import { Unassigned } from "@bitwarden/admin-console/common"; +import { Unassigned } from "@bitwarden/common/admin-console/models/collections"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts index 37b881406e3..d23fc7f958a 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts @@ -32,12 +32,12 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SyncService } from "@bitwarden/common/platform/sync"; import { DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { OrganizationFilter } from "@bitwarden/vault"; import { OrganizationUserResetPasswordService } from "../../../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { EnrollMasterPasswordReset } from "../../../../admin-console/organizations/users/enroll-master-password-reset.component"; import { LinkSsoService } from "../../../../auth/core/services"; import { OptionsInput } from "../shared/components/vault-filter-section.component"; -import { OrganizationFilter } from "../shared/models/vault-filter.type"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 8839fa5039d..234b227c76f 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -27,22 +27,19 @@ import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; - -import { VaultFilterService } from "../services/abstractions/vault-filter.service"; import { + VaultFilterServiceAbstraction as VaultFilterService, VaultFilterList, VaultFilterSection, VaultFilterType, -} from "../shared/models/vault-filter-section.type"; -import { VaultFilter } from "../shared/models/vault-filter.model"; -import { + VaultFilter, CipherStatus, CipherTypeFilter, CollectionFilter, FolderFilter, OrganizationFilter, -} from "../shared/models/vault-filter.type"; +} from "@bitwarden/vault"; +import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { OrganizationOptionsComponent } from "./organization-options.component"; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.ts index 6ebbdc84c73..6443e143980 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.ts @@ -8,10 +8,12 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; - -import { VaultFilterService } from "../../services/abstractions/vault-filter.service"; -import { VaultFilterSection, VaultFilterType } from "../models/vault-filter-section.type"; -import { VaultFilter } from "../models/vault-filter.model"; +import { + VaultFilterServiceAbstraction as VaultFilterService, + VaultFilterSection, + VaultFilterType, + VaultFilter, +} from "@bitwarden/vault"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/vault-filter.module.ts b/apps/web/src/app/vault/individual-vault/vault-filter/vault-filter.module.ts index dc70561bcb2..4d98bcd42bc 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/vault-filter.module.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/vault-filter.module.ts @@ -1,14 +1,13 @@ import { NgModule } from "@angular/core"; import { SearchModule } from "@bitwarden/components"; +import { VaultFilterServiceAbstraction, VaultFilterService } from "@bitwarden/vault"; import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { VaultFilterSharedModule } from "../../individual-vault/vault-filter/shared/vault-filter-shared.module"; import { OrganizationOptionsComponent } from "./components/organization-options.component"; import { VaultFilterComponent } from "./components/vault-filter.component"; -import { VaultFilterService as VaultFilterServiceAbstraction } from "./services/abstractions/vault-filter.service"; -import { VaultFilterService } from "./services/vault-filter.service"; @NgModule({ imports: [VaultFilterSharedModule, SearchModule, OrganizationWarningsModule], diff --git a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts index 8fa801f5dc0..e7c14825fe2 100644 --- a/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-header/vault-header.component.ts @@ -3,13 +3,13 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from import { Router } from "@angular/router"; import { firstValueFrom, switchMap } from "rxjs"; +import { CollectionAdminService } from "@bitwarden/admin-console/common"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Unassigned, CollectionView, - CollectionAdminService, CollectionTypes, -} from "@bitwarden/admin-console/common"; -import { JslibModule } from "@bitwarden/angular/jslib.module"; +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -24,16 +24,12 @@ import { MenuModule, SimpleDialogOptions, } from "@bitwarden/components"; -import { NewCipherMenuComponent } from "@bitwarden/vault"; +import { NewCipherMenuComponent, All, RoutedVaultFilterModel } from "@bitwarden/vault"; import { CollectionDialogTabType } from "../../../admin-console/organizations/shared/components/collection-dialog"; import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; import { PipesModule } from "../pipes/pipes.module"; -import { - All, - RoutedVaultFilterModel, -} from "../vault-filter/shared/models/routed-vault-filter.model"; @Component({ selector: "app-vault-header", diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index aa238922eea..527df0b5370 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -25,13 +25,7 @@ import { tap, } from "rxjs/operators"; -import { - CollectionData, - CollectionDetailsResponse, - CollectionService, - CollectionView, - Unassigned, -} from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { NoResults, @@ -50,7 +44,17 @@ import { } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionDetailsResponse, + CollectionView, + Unassigned, + CollectionData, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { + getNestedCollectionTree, + getFlatCollectionTree, +} from "@bitwarden/common/admin-console/utils"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; @@ -97,6 +101,15 @@ import { DecryptionFailureDialogComponent, DefaultCipherFormConfigService, PasswordRepromptService, + VaultFilterServiceAbstraction as VaultFilterService, + RoutedVaultFilterBridgeService, + RoutedVaultFilterService, + createFilterFunction, + All, + RoutedVaultFilterModel, + VaultFilter, + FolderFilter, + OrganizationFilter, VaultItemsTransferService, DefaultVaultItemsTransferService, } from "@bitwarden/vault"; @@ -104,10 +117,6 @@ import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/in import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; -import { - getNestedCollectionTree, - getFlatCollectionTree, -} from "../../admin-console/organizations/collections"; import { AutoConfirmPolicy, AutoConfirmPolicyDialogComponent, @@ -140,16 +149,6 @@ import { } from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component"; import { VaultBannersComponent } from "./vault-banners/vault-banners.component"; import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component"; -import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service"; -import { RoutedVaultFilterBridgeService } from "./vault-filter/services/routed-vault-filter-bridge.service"; -import { RoutedVaultFilterService } from "./vault-filter/services/routed-vault-filter.service"; -import { createFilterFunction } from "./vault-filter/shared/models/filter-function"; -import { - All, - RoutedVaultFilterModel, -} from "./vault-filter/shared/models/routed-vault-filter.model"; -import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model"; -import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/vault-filter.type"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { VaultOnboardingComponent } from "./vault-onboarding/vault-onboarding.component"; @@ -747,7 +746,8 @@ export class VaultComponent implements OnInit, OnDestr const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "archiveItem" }, - content: { key: "archiveItemConfirmDesc" }, + content: { key: "archiveItemDialogContent" }, + acceptButtonText: { key: "archiveVerb" }, type: "info", }); diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts index c6a7c9c830d..bd97ed4ea55 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts @@ -1,18 +1,18 @@ import { TestBed } from "@angular/core/testing"; import { BehaviorSubject, of } from "rxjs"; -import { CollectionAdminService, CollectionAdminView } from "@bitwarden/admin-console/common"; +import { CollectionAdminService } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; - -import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; +import { RoutedVaultFilterService } from "@bitwarden/vault"; import { AdminConsoleCipherFormConfigService } from "./admin-console-cipher-form-config.service"; diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts index 939729568e9..01a2f23f4e1 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts @@ -16,9 +16,12 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; -import { CipherFormConfig, CipherFormConfigService, CipherFormMode } from "@bitwarden/vault"; - -import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; +import { + CipherFormConfig, + CipherFormConfigService, + CipherFormMode, + RoutedVaultFilterService, +} from "@bitwarden/vault"; /** Admin Console implementation of the `CipherFormConfigService`. */ @Injectable() diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 6175da95ec8..32a3baa4de0 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -5616,6 +5616,37 @@ "message": "Send geskep", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send gewysig", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index dd114daf78e..573d5aa5b01 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -5616,6 +5616,37 @@ "message": "إرسال محفوظ", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "إرسال محفوظ", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index 0cb76ce4cb8..b1e1ec33b28 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -2541,7 +2541,7 @@ "message": "Fəallaşdırıldı" }, "optionEnabled": { - "message": "Enabled" + "message": "Fəaldır" }, "restoreAccess": { "message": "Erişimi bərpa et" @@ -3153,7 +3153,7 @@ "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." }, "itemRestored": { - "message": "Item has been restored" + "message": "Element bərpa edildi" }, "restartPremium": { "message": "\"Premium\"u yenidən başlat" @@ -5616,6 +5616,37 @@ "message": "Send yaradıldı", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send uğurla yaradıldı!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Bu Send keçidini kopyala və paylaş. Qeyd etdiyiniz şəxslər buna növbəti $TIME$ ərzində baxa bilər.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ saat", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Yeni Send mətni", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Yeni Send faylı", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "\"Send\"ə düzəliş edildi", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5656,7 +5687,7 @@ "message": "Ləğv edildi" }, "accepted": { - "message": "Accepted" + "message": "Qəbul edildi" }, "sendLink": { "message": "\"Send\" keçidi", @@ -6317,10 +6348,10 @@ "message": "Hesab geri qaytarılmasına yazıldınız" }, "enrolled": { - "message": "Enrolled" + "message": "Yazılma uğurludur" }, "notEnrolled": { - "message": "Not enrolled" + "message": "Yazılma baş tutmayıb" }, "withdrawAccountRecovery": { "message": "Hesab geri qaytarılması üzrə razılığı geri götür" @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "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." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Əskik veb sayt" }, @@ -11628,7 +11665,7 @@ "message": "Arxivdən çıxart" }, "archived": { - "message": "Archived" + "message": "Arxivləndi" }, "unArchiveAndSave": { "message": "Arxivdən çıxart və saxla" @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Elementlər arxivə göndərildi" }, + "itemWasUnarchived": { + "message": "Element arxivdən çıxarıldı" + }, "itemUnarchived": { "message": "Element arxivdən çıxarıldı" }, @@ -12508,16 +12548,16 @@ "message": "Tam onlayn təhlükəsizlik" }, "updatePayment": { - "message": "Update payment" + "message": "Ödənişi güncəllə" }, "weCouldNotProcessYourPayment": { - "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + "message": "Ödənişiniz emal olunmadı. Lütfən ödəniş üsulunuzu güncəlləyin, ya da kömək üçün dəstək komandası ilə əlaqə saxlayın." }, "yourSubscriptionHasExpired": { - "message": "Your subscription has expired. Please contact the support team for assistance." + "message": "Abunəliyinizin vaxtı bitib. Kömək üçün lütfən dəstək komandası ilə əlaqə saxlayın." }, "yourSubscriptionIsScheduledToCancel": { - "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "message": "Abunəliyiniz $DATE$ tarixində ləğv ediləcək. Abunəliyinizi bu tarixdən əvvəl təkrar aktivləşdirə bilərsiniz.", "placeholders": { "date": { "content": "$1", @@ -12526,10 +12566,10 @@ } }, "premiumShareEvenMore": { - "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + "message": "Ailələr planı ilə daha çoxunu paylaşın, ya da Komandalar və ya Müəssisə planı ilə daha güclü və etibarlı parol təhlükəsizliyi əldə edin." }, "youHaveAGracePeriod": { - "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "message": "Abunəliyinizin bitmə tarixindən etibarən $DAYS$ gün güzəşt dövrünüz var. Lütfən $DATE$ tarixinə qədər vaxtı keçmiş fakturaları həll edin.", "placeholders": { "days": { "content": "$1", @@ -12542,31 +12582,31 @@ } }, "manageInvoices": { - "message": "Manage invoices" + "message": "Fakturaları idarə et" }, "yourNextChargeIsFor": { - "message": "Your next charge is for" + "message": "Növbəti ödəniş haqqı" }, "dueOn": { - "message": "due on" + "message": "tarix" }, "yourSubscriptionWillBeSuspendedOn": { - "message": "Your subscription will be suspended on" + "message": "Abunəliyinizin fəaliyyəti dayandırılacaq" }, "yourSubscriptionWasSuspendedOn": { - "message": "Your subscription was suspended on" + "message": "Abunəliyinizin fəaliyyəti dayandırıldı" }, "yourSubscriptionWillBeCanceledOn": { - "message": "Your subscription will be canceled on" + "message": "Abunəliyiniz ləğv ediləcək" }, "yourSubscriptionWasCanceledOn": { - "message": "Your subscription was canceled on" + "message": "Abunəliyiniz ləğv edildi" }, "storageFull": { - "message": "Storage full" + "message": "Anbar dolub" }, "storageUsedDescription": { - "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "message": "$AVAILABLE$ GB şifrələnmiş fayl anbarınızın $USED$ qədəri istifadə olunub.", "placeholders": { "used": { "content": "$1", @@ -12579,6 +12619,15 @@ } }, "storageFullDescription": { - "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + "message": "Bütün $GB$ GB-lıq şifrələnmiş anbar sahənizi istifadə etmisiniz. Faylları saxlaya bilmək üçün daha çox anbar sahəsi əlavə edin." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index 600eb8cea92..5fc19a5cb81 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -5616,6 +5616,37 @@ "message": "Створаны Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send адрэдагаваны", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index dc2dfab4488..cd0f219e47a 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -5616,6 +5616,37 @@ "message": "Създадено изпращане", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Изпращането е създадено успешно!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Копирайте и споделете връзка към това Изпращане. То ще може да бъде видяно само от хората, които сте посочили, в рамките на следващите $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ часа", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Ново текстово Изпращане", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Ново файлово Изпращане", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Редактирано изпращане", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Този елемент за вписване е в риск и в него липсва уеб сайт. Добавете уеб сайт и сменете паролата, за по-добра сигурност." }, + "vulnerablePassword": { + "message": "Уязвима парола." + }, + "changeNow": { + "message": "Промяна сега" + }, "missingWebsite": { "message": "Липсващ уеб сайт" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Елементите бяха преместени в архива" }, + "itemWasUnarchived": { + "message": "Елементът беше изваден от архива" + }, "itemUnarchived": { "message": "Елементът беше изваден от архива" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "Използвали сте всичките си $GB$ GB от наличното си място за съхранение на шифровани данни. Ако искате да продължите да добавяте файлове, добавете повече място за съхранение." + }, + "whenYouRemoveStorage": { + "message": "Когато премахнете съхранението, ще получите пропорционално задължение към акаунта си, което ще бъде включено автоматично в следващата Ви сметка." + }, + "youHavePremium": { + "message": "Имате платен абонамент" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index bf8fbc1e603..1d3901581ab 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index be24796ae06..aef132431e9 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index 9a5f5244f35..190cdfe9aa4 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -5616,6 +5616,37 @@ "message": "Send creat", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send guardat", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 5fcfa8d8edc..0ea73451e1d 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -5616,6 +5616,37 @@ "message": "Send byl uložen", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send byl úspěšně vytvořen!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Zkopírujte a sdílejte tento Send pro odesílání. Můžou jej zobrazit osoby, které jste zadali, a to po dobu $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hodin", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Nový textový Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Nový Send se soubory", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send byl uložen", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Tyto přihlašovací údaje jsou ohrožené a chybí jim webová stránka. Přidejte webovou stránku a změňte heslo pro větší bezpečnost." }, + "vulnerablePassword": { + "message": "Zranitelné heslo." + }, + "changeNow": { + "message": "Změnit nyní" + }, "missingWebsite": { "message": "Chybějící webová stránka" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Položky byly přesunuty do archivu" }, + "itemWasUnarchived": { + "message": "Položka byla odebrána z archivu" + }, "itemUnarchived": { "message": "Položka byla odebrána z archivu" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "Využili jste celých $GB$ GB Vašeho šifrovaného úložiště. Chcete-li pokračovat v ukládání souborů, přidejte další úložiště." + }, + "whenYouRemoveStorage": { + "message": "Když odeberete úložiště, obdržíte kredit, který bude automaticky převeden do Vašeho dalšího vyúčtování." + }, + "youHavePremium": { + "message": "Máte Premium" + }, + "emailProtected": { + "message": "E-mail je chráněný" } } diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index f8028a95b2d..da522125f28 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 8526782a2e5..05868c77e85 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -5616,6 +5616,37 @@ "message": "Send gemt", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send gemt", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 40f3e64ec1f..c200857fb47 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -5616,6 +5616,37 @@ "message": "Send gespeichert", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send erfolgreich erstellt!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Kopiere und teile diesen Send-Link. Er kann von den von dir angegebenen Personen für die nächsten $TIME$ angesehen werden.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ Stunden", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Neues Text-Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Neues Datei-Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send gespeichert", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Diese Zugangsdaten sind gefährdet und es fehlt eine Website. Füge eine Website hinzu und ändere das Passwort für mehr Sicherheit." }, + "vulnerablePassword": { + "message": "Gefährdetes Passwort." + }, + "changeNow": { + "message": "Jetzt ändern" + }, "missingWebsite": { "message": "Fehlende Webseite" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Einträge wurden ins Archiv verschoben" }, + "itemWasUnarchived": { + "message": "Eintrag wird nicht mehr archiviert" + }, "itemUnarchived": { "message": "Eintrag wird nicht mehr archiviert" }, @@ -12563,7 +12603,7 @@ "message": "Dein Abonnement wurde gekündigt am" }, "storageFull": { - "message": "Speicher voll" + "message": "Speicherplatz voll" }, "storageUsedDescription": { "message": "Du hast $USED$ von $AVAILABLE$ GB deines verschlüsselten Datenspeichers verwendet.", @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "Du hast die gesamten $GB$ GB deines verschlüsselten Speichers verwendet. Um mit dem Speichern von Dateien fortzufahren, füge mehr Speicher hinzu." + }, + "whenYouRemoveStorage": { + "message": "Wenn du Speicherplatz entfernst, erhältst du eine anteilige Gutschrift, die automatisch mit deiner nächsten Rechnung verrechnet wird." + }, + "youHavePremium": { + "message": "Du hast Premium" + }, + "emailProtected": { + "message": "E-Mail-Adresse geschützt" } } diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index 22fef811575..3d0bc52b9b2 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -5616,6 +5616,37 @@ "message": "Το Send Δημιουργήθηκε", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Το Send Επεξεργάστηκε", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 8024de21e56..b438920a99f 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11588,6 +11619,12 @@ }, "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" }, "missingWebsite": { "message": "Missing website" @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -11661,8 +11701,8 @@ "message": "Archive item", "description": "Verb" }, - "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." }, "archiveBulkItems": { "message": "Archive items", @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index e940b18682e..b9ff96a119f 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index a4e904c4e3f..6fbee21406e 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -5616,6 +5616,37 @@ "message": "Created Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Edited Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index ecf4ea16a31..b1b2ee4dae9 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -5616,6 +5616,37 @@ "message": "Kreita Sendo", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Redaktita Sendo", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 1ed117a249c..6d6895eaaba 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -5616,6 +5616,37 @@ "message": "Send creado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send editado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index a657243582b..722b715aa0b 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -5616,6 +5616,37 @@ "message": "Send on loodud", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Muutis Sendi", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index c5fbb39790b..f05450a68b2 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -5616,6 +5616,37 @@ "message": "Send-a sortua", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send-a editatua", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index 17c4ba2a1e9..5a05f9b44dc 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -5616,6 +5616,37 @@ "message": "ارسال ذخیره شد", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "ارسال ذخیره شد", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index 6c72705fd8e..8cbe7cffb11 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -5616,6 +5616,37 @@ "message": "Send tallennettiin", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send tallennettiin", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index c7c0fb18775..3e73d3e8332 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -5616,6 +5616,37 @@ "message": "Ipadala na nilikha", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Ipadala na nai-save", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index cbf4edfe4b8..5326586a660 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -5616,6 +5616,37 @@ "message": "Send créé", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send créé avec succès !", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copiez et partagez ce lien Send. Il peut être consulté par les personnes que vous avez spécifiées pour $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ heures", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Nouveau texte Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Nouveau fichier Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send modifié", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Cet identifiant est à risques et manque un site web. Ajoutez un site web et changez le mot de passe pour une meilleure sécurité." }, + "vulnerablePassword": { + "message": "Mot de passe vulnérable." + }, + "changeNow": { + "message": "Changer maintenant" + }, "missingWebsite": { "message": "Site Web manquant" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Les éléments ont été envoyés à l'archive" }, + "itemWasUnarchived": { + "message": "L'élément a été désarchivé" + }, "itemUnarchived": { "message": "L'élément a été désarchivé" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "Vous avez utilisé tous les $GB$ Go de votre stockage chiffré. Pour continuer à stocker des fichiers, ajoutez plus de stockage." + }, + "whenYouRemoveStorage": { + "message": "Lorsque vous supprimez le stockage, vous recevrez un crédit de compte au prorata qui sera automatiquement appliqué à votre prochaine facture." + }, + "youHavePremium": { + "message": "Vous avez Premium" + }, + "emailProtected": { + "message": "Protégé par courriel" } } diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index facbb21f5a4..7a1b82865a8 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 5f1969206c2..b602fcbe9e6 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -5616,6 +5616,37 @@ "message": "סֵנְד נשמר", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "סֵנְד נשמר", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "כניסה זו נמצאת בסיכון וחסר לה אתר אינטרנט. הוסף אתר אינטרנט ושנה את הסיסמה עבור אבטחה חזקה יותר." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "אתר אינטרנט חסר" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "פריטים שנשלחו לארכיון" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "הפריט הוסר מהארכיון" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index eb1fc912225..60f5b3465d6 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index 14d469d5d36..56169d3c8e9 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -5616,6 +5616,37 @@ "message": "Send stvoren", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send uređen", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "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." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Nedostaje web-stranica" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Stavke poslane u arhivu" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Stavka vraćena iz arhive" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index 76cf76f205c..6c8ca99c5e8 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -5616,6 +5616,37 @@ "message": "A Send mentésre került.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "A Send sikeresen létrejött!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Másoljuk és osszuk meg ezt a Send hivatkozást. Megtekinthetik a megadott személyek a következő $TIME$ intervallumban.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ óra", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Új szöveges Send elem", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Új fájl Send elem", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "A Send szerkesztésre került.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Ez a bejelentkezés veszélyben van és hiányzik egy webhely. Adjunk hozzá egy webhelyet és módosítsuk a jelszót az erősebb biztonság érdekében." }, + "vulnerablePassword": { + "message": "A jelszó sérülékeny." + }, + "changeNow": { + "message": "Módosítás most" + }, "missingWebsite": { "message": "Hiányzó webhely" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Az elemek az archivumba kerültek." }, + "itemWasUnarchived": { + "message": "Az elem visszavételre került az archivumból." + }, "itemUnarchived": { "message": "Az elemek visszavéelre kerültek az archivumból." }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "A titkosított tárhely összes $GB$ mérete felhasználásra került. A fájlok tárolásának folytatásához adjunk hozzá további tárhelyet." + }, + "whenYouRemoveStorage": { + "message": "A tárhely eltávolításakor arányos számlajóváírást kapunk, amely automatikusan a következő számlára kerül." + }, + "youHavePremium": { + "message": "Prémium felhasználó vagyunk" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index 95d393d82b0..4c158999da8 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -179,16 +179,16 @@ } }, "noDataInOrgTitle": { - "message": "No data found" + "message": "Tidak ada data ditemukan" }, "noDataInOrgDescription": { "message": "Import your organization's login data to get started with Access Intelligence. Once you do that, you'll be able to:" }, "feature1Title": { - "message": "Mark applications as critical" + "message": "Tandai aplikasi sebagai penting" }, "feature1Description": { - "message": "This will help you remove risks to your most important applications first." + "message": "Ini membantu menghilangkan risiko pada apl terpenting Anda terlebih dahulu." }, "feature2Title": { "message": "Help members improve their security" @@ -5616,6 +5616,37 @@ "message": "Kirim disimpan", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Kirim disimpan", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index 63a659662f8..1a0435b4f04 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -5616,6 +5616,37 @@ "message": "Send salvato", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send creato con successo!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copia e condividi questo link Send: potrà essere visualizzato dalle persone che hai specificato per le prossime $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ ore", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Nuovo Send testuale", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Nuovo Send con file", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send salvato", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "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." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Sito web mancante" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Elementi archiviati" }, + "itemWasUnarchived": { + "message": "Elemento rimosso dall'archivio" + }, "itemUnarchived": { "message": "Elemento rimosso dall'archivio" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "Hai usato tutti i $GB$ GB del tuo spazio di archiviazione crittografato. Per archiviare altri file, aggiungi altro spazio." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index 79e65adbd7b..0dbf162ae09 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -5616,6 +5616,37 @@ "message": "作成した Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "編集済みの Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index a3806147491..d8e3088794d 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index 8024de21e56..b352e56ba4f 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index c0fe31906ee..88ada3bb0bb 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -5616,6 +5616,37 @@ "message": "ಕಳುಹಿಸು ರಚಿಸಲಾಗಿದೆ", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "ಕಳುಹಿಸಿದ ಸಂಪಾದನೆ", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index 3f56c424333..ededdc008ff 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -5616,6 +5616,37 @@ "message": "Send 생성함", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send 수정함", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index f1ba805ae78..1d382a9533c 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saglabāts", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send tika veiksmīgi izveidots.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Ievieto starpliktuvē un kopīgo šī Send saiti! To $TIME$ no šī brīža var apskatīt cilvēki, kurus norādīji.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ stundas", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Jauns teksta Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Jauns datnes Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saglabāts", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Šis pieteikšanās vienums ir pakļauts riskam, un tam nav norādīta tīmekļvietne. Lielākai drošībai jāpievieno tīmekļvietne un jānomaina parole." }, + "vulnerablePassword": { + "message": "Ievainojama parole." + }, + "changeNow": { + "message": "Mainīt tagad" + }, "missingWebsite": { "message": "Nav norādīta tīmekļvietne" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Vienumi tika ievietoti arhīvā" }, + "itemWasUnarchived": { + "message": "Vienums tika izņemts no arhīva" + }, "itemUnarchived": { "message": "Vienums tika izņemts no arhīva" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "Kad noņemsi krātuvi, saņemsi konta kredītu noteiktā apjomā, kas tiks automātiski izmantots nākamajā rēķinā." + }, + "youHavePremium": { + "message": "Tev ir Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index a58e8e310ad..5ab54c63025 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -5616,6 +5616,37 @@ "message": "Send സൃഷ്‌ടിച്ചു", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send തിരുത്തി", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index eb6d5b917c7..3a0696f8dbb 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index 8024de21e56..b352e56ba4f 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index 04f22415ac3..455650f4144 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -5616,6 +5616,37 @@ "message": "Laget Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Endret Send-en", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index 70b3478386f..9d6333810ce 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index ea802c9aca6..0c0cafd1a8a 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -5616,6 +5616,37 @@ "message": "Send aangemaakt", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send bewerkt", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Deze login is in gevaar en mist een website. Voeg een website toe en verander het wachtwoord voor een sterkere beveiliging." }, + "vulnerablePassword": { + "message": "Kwetsbaar wachtwoord." + }, + "changeNow": { + "message": "Nu wijzigen" + }, "missingWebsite": { "message": "Ontbrekende website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Item naar archief verzonden" }, + "itemWasUnarchived": { + "message": "Item uit het archief gehaald" + }, "itemUnarchived": { "message": "Item uit het archief gehaald" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "Wanneer je opslag verwijdert, krijg je op je volgende rekening automatisch pro-rata rekeningkrediet." + }, + "youHavePremium": { + "message": "Je hebt Premium" + }, + "emailProtected": { + "message": "E-mail beveiligd" } } diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index 44d7dbf39c1..a0f21867e4b 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index 8024de21e56..b352e56ba4f 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 7f2e8da75bd..d70ddc8490d 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -5616,6 +5616,37 @@ "message": "Wysyłka została zapisana", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Wysyłka została zapisana", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 3faa9ef7729..846eb6edd98 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -5616,6 +5616,37 @@ "message": "Send salvo", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send criado com sucesso!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copie e compartilhe este link do Send. Ele pode ser visto pelas pessoas que você especificou pelos próximos $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ horas", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Novo Send de texto", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Novo Send de arquivo", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send salvo", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Esta credencial está em risco e está sem um site. Adicione um site e altere a senha para segurança melhor." }, + "vulnerablePassword": { + "message": "Senha vulnerável." + }, + "changeNow": { + "message": "Alterar agora" + }, "missingWebsite": { "message": "Site ausente" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Itens foram enviados para o arquivo" }, + "itemWasUnarchived": { + "message": "O item foi desarquivado" + }, "itemUnarchived": { "message": "O item foi desarquivado" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "Você usou todos os $GB$ GB do seu armazenamento criptografado. Para continuar armazenando arquivos, adicione mais armazenamento." + }, + "whenYouRemoveStorage": { + "message": "Quando você remover o armazenamento, você receberá um crédito de conta proporcional que irá automaticamente para sua próxima fatura." + }, + "youHavePremium": { + "message": "Você tem o Premium" + }, + "emailProtected": { + "message": "E-mail protegido" } } diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index 1915d4b49f3..72ef9105d04 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -5616,6 +5616,37 @@ "message": "Send criado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send criado com sucesso!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copie e partilhe este link do Send. Pode ser visualizado pelas pessoas que especificou durante os próximos $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ horas", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Novo Send de texto", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Novo Send de ficheiro", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send editado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Esta credencial está em risco e não tem um site. Adicione um site e altere a palavra-passe para uma segurança mais forte." }, + "vulnerablePassword": { + "message": "Palavra-passe vulnerável." + }, + "changeNow": { + "message": "Alterar agora" + }, "missingWebsite": { "message": "Site em falta" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Os itens foram movidos para o arquivo" }, + "itemWasUnarchived": { + "message": "O item foi desarquivado" + }, "itemUnarchived": { "message": "O item foi desarquivado" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "Utilizou os $GB$ GB do seu armazenamento encriptado. Para continuar a guardar ficheiros, adicione mais espaço de armazenamento." + }, + "whenYouRemoveStorage": { + "message": "Ao remover espaço de armazenamento, receberá um crédito proporcional na conta, que será automaticamente aplicado na sua próxima fatura." + }, + "youHavePremium": { + "message": "Tem Premium" + }, + "emailProtected": { + "message": "E-mail protegido" } } diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index b8be7a0f162..4511fb0a382 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -5616,6 +5616,37 @@ "message": "Send salvat", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send salvat", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index 91f6ecf9946..292272706fe 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -5616,6 +5616,37 @@ "message": "Send сохранена", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send успешно создана!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Скопируйте и распространите эту ссылку для Send. Она может быть просмотрена указанными вами пользователями в следующие $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ час.", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Новая текстовая Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Новая файловая Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send сохранена", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Этот логин подвержен риску и у него отсутствует веб-сайт. Добавьте веб-сайт и смените пароль для большей безопасности." }, + "vulnerablePassword": { + "message": "Уязвимый пароль." + }, + "changeNow": { + "message": "Изменить сейчас" + }, "missingWebsite": { "message": "Отсутствует сайт" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Элементы были отправлены в архив" }, + "itemWasUnarchived": { + "message": "Элемент был разархивирован" + }, "itemUnarchived": { "message": "Элемент был разархивирован" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "Вы использовали все $GB$ вашего зашифрованного хранилища. Чтобы продолжить хранение файлов, добавьте дополнительное хранилище." + }, + "whenYouRemoveStorage": { + "message": "При удалении хранилища вы получите пропорциональную сумму на свой счет, которая автоматически пойдет на оплату вашего следующего счета." + }, + "youHavePremium": { + "message": "У вас Премиум" + }, + "emailProtected": { + "message": "Email защищен" } } diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index 4f0dc60ad6d..16567585ce4 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index 159f90dec1d..ac2c3d8dd52 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -5616,6 +5616,37 @@ "message": "Send vytvorený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send upravený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Toto prihlásenie je v ohrození a chýba mu webová stránka. Pridajte webovú stránku a zmeňte heslo na silnejšie zabezpečenie." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Chýbajúca webová stránka" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Položky boli archivované" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Položka bola odobraná z archívu" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index 62edf2df74a..a8f1f22037e 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -5616,6 +5616,37 @@ "message": "Pošiljka shranjena", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Pošiljka shranjena", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index cd9c52f3f02..6d212a60150 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -5616,6 +5616,37 @@ "message": "Napravljena slanja", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Izmenjena slanja", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/sr_CY/messages.json b/apps/web/src/locales/sr_CY/messages.json index d1a4b3776ef..0e82f68f820 100644 --- a/apps/web/src/locales/sr_CY/messages.json +++ b/apps/web/src/locales/sr_CY/messages.json @@ -5616,6 +5616,37 @@ "message": "Креирај „Send“", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "„Send“ уређено", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Ова пријава је ризична и недостаје веб локација. Додајте веб страницу и промените лозинку за јачу сигурност." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Недостаје веб страница" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Ставке су послате у архиву" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Ставка враћена из архиве" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index 909a17ef7c4..1f86e509b3c 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -1750,7 +1750,7 @@ "message": "Det finns inga medlemmar att visa." }, "noMembersToExport": { - "message": "There are no members to export." + "message": "Det finns inga medlemmar att exportera." }, "noEventsInList": { "message": "Det finns inga händelser att visa." @@ -2541,7 +2541,7 @@ "message": "Aktiverad" }, "optionEnabled": { - "message": "Enabled" + "message": "Aktiverat" }, "restoreAccess": { "message": "Återställ åtkomst" @@ -5616,6 +5616,37 @@ "message": "Send har sparats", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send skapades!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Kopiera och dela denna Send-länk. Den kan visas av personer som du har angivet nästa $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ timmar", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Ny Send för text", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Ny Send för fil", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send har sparats", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5656,7 +5687,7 @@ "message": "Återkallad" }, "accepted": { - "message": "Accepted" + "message": "Accepterade" }, "sendLink": { "message": "Send-länk", @@ -6317,10 +6348,10 @@ "message": "Registrerad i kontoåterställning" }, "enrolled": { - "message": "Enrolled" + "message": "Registrerade" }, "notEnrolled": { - "message": "Not enrolled" + "message": "Inte registrerade" }, "withdrawAccountRecovery": { "message": "Uttag från kontoåterställning" @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Denna inloggning är i riskzonen och saknar en webbplats. Lägg till en webbplats och ändra lösenordet för starkare säkerhet." }, + "vulnerablePassword": { + "message": "Sårbart lösenord." + }, + "changeNow": { + "message": "Ändra nu" + }, "missingWebsite": { "message": "Saknar webbplats" }, @@ -11628,7 +11665,7 @@ "message": "Avarkivera" }, "archived": { - "message": "Archived" + "message": "Arkiverade" }, "unArchiveAndSave": { "message": "Avarkivera och spara" @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Objekten har skickats till arkivet" }, + "itemWasUnarchived": { + "message": "Objektet har avarkiverats" + }, "itemUnarchived": { "message": "Objektet har avarkiverats" }, @@ -12511,7 +12551,7 @@ "message": "Uppdatera betalning" }, "weCouldNotProcessYourPayment": { - "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + "message": "Vi kunde inte behandla din betalning. Vänligen uppdatera din betalningsmetod eller kontakta supportteamet för hjälp." }, "yourSubscriptionHasExpired": { "message": "Ditt abonnemang har löpt ut. Kontakta supportteamet för hjälp." @@ -12526,7 +12566,7 @@ } }, "premiumShareEvenMore": { - "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + "message": "Dela ännu mer med Familjer eller få kraftfull, betrodd lösenordssäkerhet med Team eller Enterprise." }, "youHaveAGracePeriod": { "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/ta/messages.json b/apps/web/src/locales/ta/messages.json index 7b6eb08c614..227ac6ae29e 100644 --- a/apps/web/src/locales/ta/messages.json +++ b/apps/web/src/locales/ta/messages.json @@ -5616,6 +5616,37 @@ "message": "Send சேமிக்கப்பட்டது", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send சேமிக்கப்பட்டது", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index 8024de21e56..b352e56ba4f 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index ea103206532..f444fe29ae4 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -5616,6 +5616,37 @@ "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send saved", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index 97181dff010..28d11d24f1b 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -5616,6 +5616,37 @@ "message": "Send kaydedildi", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send başarıyla oluşturuldu.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Bu Send bağlantısını kopyalayıp paylaşın. Belirlediğiniz kişiler bağlantıyı önümüzdeki $TIME$ boyunca kullanabilir.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ saat", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "Yeni Send metni", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "Yeni Send dosyası", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send kaydedildi", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Bu hesap risk altında ve web sitesi eksik. Bir web sitesi ekleyin ve güvenliğinizi artırmak için parolayı değiştirin." }, + "vulnerablePassword": { + "message": "Güvensiz parola." + }, + "changeNow": { + "message": "Şimdi değiştir" + }, "missingWebsite": { "message": "Web sitesi eksik" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Kayıtlar arşive gönderildi" }, + "itemWasUnarchived": { + "message": "Kayıt arşivden çıkarıldı" + }, "itemUnarchived": { "message": "Kayıt arşivden çıkarıldı" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "Premium abonesiniz" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index 155b0eed971..5de4cf2b8bd 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -5616,6 +5616,37 @@ "message": "Відправлення збережено", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Відправлення збережено", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Items were sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index 803c283fa52..a2498cea4cb 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -5616,6 +5616,37 @@ "message": "Đã lưu Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Đã lưu Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Mục đăng nhập này có nguy cơ bị lộ và thiếu trang web. Thêm trang web và thay đổi mật khẩu để tăng cường bảo mật." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Thiếu trang web" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "Các mục đã được chuyển vào lưu trữ" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Mục đã được bỏ lưu trữ" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index a5cca606972..a87f850aa31 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -42,7 +42,7 @@ "message": "您还没有创建报告" }, "notifiedMembers": { - "message": "已通知的成员" + "message": "已通知成员" }, "revokeMembers": { "message": "撤销成员" @@ -170,7 +170,7 @@ } }, "notifiedMembersWithCount": { - "message": "已通知的成员 ($COUNT$)", + "message": "已通知成员 ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -365,7 +365,7 @@ "message": "全部处理完成!" }, "noNewApplicationsToReviewAtThisTime": { - "message": "目前没有新应用程序需要审查" + "message": "当前没有新应用程序需要审查" }, "organizationHasItemsSavedForApplications": { "message": "您的组织已为 $COUNT$ 个应用程序保存了项目", @@ -3954,7 +3954,7 @@ } }, "deletedItemId": { - "message": "发送项目 $ID$ 到回收站。", + "message": "发送了项目 $ID$ 到回收站。", "placeholders": { "id": { "content": "$1", @@ -4014,7 +4014,7 @@ } }, "viewedSecurityCodeItemId": { - "message": "查看了项目 $ID$ 的安全代码。", + "message": "查看了项目 $ID$ 的安全码。", "placeholders": { "id": { "content": "$1", @@ -4059,7 +4059,7 @@ } }, "copiedSecurityCodeItemId": { - "message": "复制了项目 $ID$ 的安全代码。", + "message": "复制了项目 $ID$ 的安全码。", "placeholders": { "id": { "content": "$1", @@ -5425,7 +5425,7 @@ "message": "项目已恢复" }, "restoredItemId": { - "message": "项目 $ID$ 已恢复", + "message": "恢复了项目 $ID$", "placeholders": { "id": { "content": "$1", @@ -5571,7 +5571,7 @@ "message": "组织的所有者和管理员不受此策略的约束。" }, "limitSendViews": { - "message": "查看次数限制" + "message": "限制查看次数" }, "limitSendViewsHint": { "message": "达到限额后,任何人无法查看此 Send。", @@ -5616,6 +5616,37 @@ "message": "Send 已保存", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send 创建成功!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "复制并分享此 Send 链接。您指定的人员可在接下来的 $TIME$ 内查看此 Send。", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ 小时", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "新增文本 Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "新增文件 Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send 已保存", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5707,7 +5738,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendHiddenByDefault": { - "message": "此 Send 默认隐藏。您可使用下方的按钮切换其可见性。", + "message": "此 Send 默认隐藏。您可以使用下方的按钮切换其可见性。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "downloadAttachments": { @@ -6811,7 +6842,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为「$ACTION$」。", "placeholders": { "hours": { "content": "$1", @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "此登录存在风险且缺少网站。请添加网站并更改密码以增强安全性。" }, + "vulnerablePassword": { + "message": "易受攻击的密码。" + }, + "changeNow": { + "message": "立即更改" + }, "missingWebsite": { "message": "缺少网站" }, @@ -11648,14 +11685,17 @@ "itemsWereSentToArchive": { "message": "项目已发送到归档" }, + "itemWasUnarchived": { + "message": "项目已取消归档" + }, "itemUnarchived": { "message": "项目已取消归档" }, "bulkArchiveItems": { - "message": "项目已归档" + "message": "已归档的项目" }, "bulkUnarchiveItems": { - "message": "项目已取消归档" + "message": "已取消归档的项目" }, "archiveItem": { "message": "归档项目", @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "您已使用了全部的 $GB$ GB 加密存储空间。要继续存储文件,请添加更多存储空间。" + }, + "whenYouRemoveStorage": { + "message": "当您移除存储空间时,您将收到一笔按比例计算的账户信用额度,其将用于自动抵扣您的下一笔费用。" + }, + "youHavePremium": { + "message": "您拥有高级版" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index 5fc80815483..5fe77ca13c6 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -5616,6 +5616,37 @@ "message": "Send 已儲存", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendCreatedSuccessfully": { + "message": "Send created successfully!", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendCreatedDescription": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days" + } + } + }, + "durationTimeHours": { + "message": "$HOURS$ hours", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + } + } + }, + "newTextSend": { + "message": "New Text Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newFileSend": { + "message": "New File Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "editedSend": { "message": "Send 已儲存", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -11589,6 +11620,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "此登入資訊存在風險,且缺少網站。請新增網站並變更密碼以提升安全性。" }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "缺少網站" }, @@ -11648,6 +11685,9 @@ "itemsWereSentToArchive": { "message": "項目已移至封存" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "項目取消封存" }, @@ -12580,5 +12620,14 @@ }, "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + }, + "whenYouRemoveStorage": { + "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + }, + "youHavePremium": { + "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts index 050ec8df944..88af8081a8b 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-orchestrator.service.ts @@ -140,7 +140,10 @@ export class RiskInsightsOrchestratorService { reportProgress$ = this._reportProgressSubject.asObservable(); // --------------------------- Critical Application data --------------------- - criticalReportResults$: Observable = of(null); + private _criticalReportResultsSubject = new BehaviorSubject( + null, + ); + criticalReportResults$ = this._criticalReportResultsSubject.asObservable(); // --------------------------- Trigger subjects --------------------- private _initializeOrganizationTriggerSubject = new Subject(); @@ -989,7 +992,7 @@ export class RiskInsightsOrchestratorService { // Setup the pipeline to create a report view filtered to only critical applications private _setupCriticalApplicationReport() { const criticalReportResultsPipeline$ = this.enrichedReportData$.pipe( - filter((state) => !!state), + filter((state) => !!state && !!state.summaryData), map((enrichedReports) => { const criticalApplications = enrichedReports!.reportData.filter( (app) => app.isMarkedAsCritical, @@ -997,11 +1000,11 @@ export class RiskInsightsOrchestratorService { // Generate a new summary based on just the critical applications const summary = this.reportService.getApplicationsSummary( criticalApplications, - enrichedReports.applicationData, - enrichedReports.summaryData.totalMemberCount, + enrichedReports!.applicationData, + enrichedReports!.summaryData.totalMemberCount, ); return { - ...enrichedReports, + ...enrichedReports!, summaryData: summary, reportData: criticalApplications, }; @@ -1009,7 +1012,9 @@ export class RiskInsightsOrchestratorService { shareReplay({ bufferSize: 1, refCount: true }), ); - this.criticalReportResults$ = criticalReportResultsPipeline$; + criticalReportResultsPipeline$.pipe(takeUntil(this._destroy$)).subscribe((data) => { + this._criticalReportResultsSubject.next(data); + }); } /** diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.html new file mode 100644 index 00000000000..e0b29dffeb8 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.html @@ -0,0 +1,225 @@ + + + + + + +
    + + + {{ "all" | i18n }} + + {{ allCount }} + + + + {{ "invited" | i18n }} + + {{ invitedCount }} + + + + {{ "needsConfirmation" | i18n }} + + {{ acceptedCount }} + + + +
    + + + + {{ "loading" | i18n }} + + + +

    {{ "noMembersInList" | i18n }}

    + + + {{ "providerUsersNeedConfirmed" | i18n }} + + + + + + + + + + {{ "name" | i18n }} + {{ "role" | i18n }} + + + + + + + + + + + + + + + + +
    + +
    +
    + + + {{ "invited" | i18n }} + + + {{ "needsConfirmation" | i18n }} + + + {{ "revoked" | i18n }} + +
    +
    + {{ user.email }} +
    +
    +
    + + + {{ "providerAdmin" | i18n }} + {{ "serviceUser" | i18n }} + + + + + + + + + + + +
    +
    +
    +
    +
    diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts new file mode 100644 index 00000000000..004b0a8f7c9 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts @@ -0,0 +1,338 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { combineLatest, firstValueFrom, lastValueFrom, switchMap } from "rxjs"; +import { first, map } from "rxjs/operators"; + +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderUserStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; +import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-confirm.request"; +import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { assertNonNullish } from "@bitwarden/common/auth/utils"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +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"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { ProviderId } from "@bitwarden/common/types/guid"; +import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; +import { BaseMembersComponent } from "@bitwarden/web-vault/app/admin-console/common/base-members.component"; +import { + CloudBulkReinviteLimit, + MaxCheckedCount, + peopleFilter, + PeopleTableDataSource, +} from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; +import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component"; +import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component"; +import { MemberActionResult } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service"; + +import { + AddEditMemberDialogComponent, + AddEditMemberDialogParams, + AddEditMemberDialogResultType, +} from "./dialogs/add-edit-member-dialog.component"; +import { BulkConfirmDialogComponent } from "./dialogs/bulk-confirm-dialog.component"; +import { BulkRemoveDialogComponent } from "./dialogs/bulk-remove-dialog.component"; + +type ProviderUser = ProviderUserUserDetailsResponse; + +class MembersTableDataSource extends PeopleTableDataSource { + protected statusType = ProviderUserStatusType; +} + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + templateUrl: "deprecated_members.component.html", + standalone: false, +}) +export class MembersComponent extends BaseMembersComponent { + accessEvents = false; + dataSource: MembersTableDataSource; + loading = true; + providerId: string; + rowHeight = 70; + rowHeightClass = `tw-h-[70px]`; + status: ProviderUserStatusType = null; + + userStatusType = ProviderUserStatusType; + userType = ProviderUserType; + + constructor( + apiService: ApiService, + keyService: KeyService, + dialogService: DialogService, + i18nService: I18nService, + logService: LogService, + organizationManagementPreferencesService: OrganizationManagementPreferencesService, + toastService: ToastService, + userNamePipe: UserNamePipe, + validationService: ValidationService, + private encryptService: EncryptService, + private activatedRoute: ActivatedRoute, + private providerService: ProviderService, + private router: Router, + private accountService: AccountService, + private configService: ConfigService, + private environmentService: EnvironmentService, + ) { + super( + apiService, + i18nService, + keyService, + validationService, + logService, + userNamePipe, + dialogService, + organizationManagementPreferencesService, + toastService, + ); + + this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + + combineLatest([ + this.activatedRoute.parent.params, + this.activatedRoute.queryParams.pipe(first()), + ]) + .pipe( + switchMap(async ([urlParams, queryParams]) => { + this.searchControl.setValue(queryParams.search); + this.dataSource.filter = peopleFilter(queryParams.search, null); + + this.providerId = urlParams.providerId; + const provider = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.providerService.get$(this.providerId, userId)), + ), + ); + + if (!provider || !provider.canManageUsers) { + return await this.router.navigate(["../"], { relativeTo: this.activatedRoute }); + } + this.accessEvents = provider.useEvents; + await this.load(); + + if (queryParams.viewEvents != null) { + const user = this.dataSource.data.find((user) => user.id === queryParams.viewEvents); + if (user && user.status === ProviderUserStatusType.Confirmed) { + this.openEventsDialog(user); + } + } + }), + takeUntilDestroyed(), + ) + .subscribe(); + } + + async bulkConfirm(): Promise { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + const dialogRef = BulkConfirmDialogComponent.open(this.dialogService, { + data: { + providerId: this.providerId, + users: users, + }, + }); + + await lastValueFrom(dialogRef.closed); + await this.load(); + } + + async bulkReinvite(): Promise { + if (this.actionPromise != null) { + return; + } + + let users: ProviderUser[]; + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + users = this.dataSource.getCheckedUsersInVisibleOrder(); + } else { + users = this.dataSource.getCheckedUsers(); + } + + const allInvitedUsers = users.filter((user) => user.status === ProviderUserStatusType.Invited); + + // Capture the original count BEFORE enforcing the limit + const originalInvitedCount = allInvitedUsers.length; + + // When feature flag is enabled, limit invited users and uncheck the excess + let checkedInvitedUsers: ProviderUser[]; + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + checkedInvitedUsers = this.dataSource.limitAndUncheckExcess( + allInvitedUsers, + CloudBulkReinviteLimit, + ); + } else { + checkedInvitedUsers = allInvitedUsers; + } + + if (checkedInvitedUsers.length <= 0) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("noSelectedUsersApplicable"), + }); + return; + } + + try { + // When feature flag is enabled, show toast instead of dialog + if (this.dataSource.isIncreasedBulkLimitEnabled()) { + await this.apiService.postManyProviderUserReinvite( + this.providerId, + new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)), + ); + + const selectedCount = originalInvitedCount; + const invitedCount = checkedInvitedUsers.length; + + if (selectedCount > CloudBulkReinviteLimit) { + const excludedCount = selectedCount - CloudBulkReinviteLimit; + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t( + "bulkReinviteLimitedSuccessToast", + CloudBulkReinviteLimit.toLocaleString(), + selectedCount.toLocaleString(), + excludedCount.toLocaleString(), + ), + }); + } else { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("bulkReinviteSuccessToast", invitedCount.toString()), + }); + } + } else { + // Feature flag disabled - show legacy dialog + const request = this.apiService.postManyProviderUserReinvite( + this.providerId, + new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)), + ); + + const dialogRef = BulkStatusComponent.open(this.dialogService, { + data: { + users: users, + filteredUsers: checkedInvitedUsers, + request, + successfulMessage: this.i18nService.t("bulkReinviteMessage"), + }, + }); + await lastValueFrom(dialogRef.closed); + } + } catch (error) { + this.validationService.showError(error); + } + } + + async invite() { + await this.edit(null); + } + + async bulkRemove(): Promise { + if (this.actionPromise != null) { + return; + } + + const users = this.dataSource.getCheckedUsersWithLimit(MaxCheckedCount); + + const dialogRef = BulkRemoveDialogComponent.open(this.dialogService, { + data: { + providerId: this.providerId, + users: users, + }, + }); + + await lastValueFrom(dialogRef.closed); + await this.load(); + } + + async confirmUser(user: ProviderUser, publicKey: Uint8Array): Promise { + try { + const providerKey = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.keyService.providerKeys$(userId)), + map((providerKeys) => providerKeys?.[this.providerId as ProviderId] ?? null), + ), + ); + assertNonNullish(providerKey, "Provider key not found"); + + const key = await this.encryptService.encapsulateKeyUnsigned(providerKey, publicKey); + const request = new ProviderUserConfirmRequest(key.encryptedString); + await this.apiService.postProviderUserConfirm(this.providerId, user.id, request); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + removeUser = async (id: string): Promise => { + try { + await this.apiService.deleteProviderUser(this.providerId, id); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; + + edit = async (user: ProviderUser | null): Promise => { + const data: AddEditMemberDialogParams = { + providerId: this.providerId, + user, + }; + + const dialogRef = AddEditMemberDialogComponent.open(this.dialogService, { + data, + }); + + const result = await lastValueFrom(dialogRef.closed); + + switch (result) { + case AddEditMemberDialogResultType.Saved: + case AddEditMemberDialogResultType.Deleted: + await this.load(); + break; + } + }; + + openEventsDialog = (user: ProviderUser): DialogRef => + openEntityEventsDialog(this.dialogService, { + data: { + name: this.userNamePipe.transform(user), + providerId: this.providerId, + entityId: user.id, + showUser: false, + entity: "user", + }, + }); + + getUsers = (): Promise> => + this.apiService.getProviderUsers(this.providerId); + + reinviteUser = async (id: string): Promise => { + try { + await this.apiService.postProviderUserReinvite(this.providerId, id); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + }; +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts index 635aaf16b3f..1579e0409d1 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/add-edit-member-dialog.component.ts @@ -3,6 +3,7 @@ import { Component, Inject } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { ProviderUserInviteRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-invite.request"; @@ -15,14 +16,11 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { ProviderUser } from "@bitwarden/web-vault/app/admin-console/common/people-table-data-source"; export type AddEditMemberDialogParams = { providerId: string; - user?: { - id: string; - name: string; - type: ProviderUserType; - }; + user?: ProviderUser; }; // FIXME: update to use a const object instead of a typescript enum @@ -59,6 +57,7 @@ export class AddEditMemberDialogComponent { private dialogService: DialogService, private i18nService: I18nService, private toastService: ToastService, + private userNamePipe: UserNamePipe, ) { this.editing = this.loading = this.dialogParams.user != null; if (this.editing) { @@ -78,8 +77,10 @@ export class AddEditMemberDialogComponent { return; } + const userName = this.userNamePipe.transform(this.dialogParams.user); + const confirmed = await this.dialogService.openSimpleDialog({ - title: this.dialogParams.user.name, + title: userName, content: { key: "removeUserConfirmation" }, type: "warning", }); @@ -96,7 +97,7 @@ export class AddEditMemberDialogComponent { this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t("removedUserId", this.dialogParams.user.name), + message: this.i18nService.t("removedUserId", userName), }); this.dialogRef.close(AddEditMemberDialogResultType.Deleted); @@ -118,13 +119,12 @@ export class AddEditMemberDialogComponent { await this.apiService.postProviderUserInvite(this.dialogParams.providerId, request); } + const userName = this.editing ? this.userNamePipe.transform(this.dialogParams.user) : undefined; + this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t( - this.editing ? "editedUserId" : "invitedUsers", - this.dialogParams.user?.name, - ), + message: this.i18nService.t(this.editing ? "editedUserId" : "invitedUsers", userName), }); this.dialogRef.close(AddEditMemberDialogResultType.Saved); diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts index 7ade77ed01b..84bd6988f0b 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-confirm-dialog.component.ts @@ -36,6 +36,7 @@ type BulkConfirmDialogParams = { @Component({ templateUrl: "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.html", + selector: "provider-bulk-comfirm-dialog", standalone: false, }) export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts index 29b50f71c1b..c044b9379c5 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/dialogs/bulk-remove-dialog.component.ts @@ -21,6 +21,7 @@ type BulkRemoveDialogParams = { @Component({ templateUrl: "../../../../../../../../apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove-dialog.component.html", + selector: "provider-bulk-remove-dialog", standalone: false, }) export class BulkRemoveDialogComponent extends BaseBulkRemoveComponent { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html index e0b29dffeb8..143693ed1d7 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.html @@ -1,7 +1,13 @@ +@let providerId = providerId$ | async; +@let bulkMenuOptions = bulkMenuOptions$ | async; +@let showConfirmBanner = showConfirmBanner$ | async; +@let dataSource = this.dataSource(); +@let isProcessing = this.isProcessing(); + - @@ -13,28 +19,28 @@ (selectedChange)="statusToggle.next($event)" [attr.aria-label]="'memberStatusFilter' | i18n" > - + {{ "all" | i18n }} - - {{ allCount }} - + @if (dataSource.activeUserCount; as allCount) { + {{ allCount }} + } {{ "invited" | i18n }} - - {{ invitedCount }} - + @if (dataSource.invitedUserCount; as invitedCount) { + {{ invitedCount }} + } {{ "needsConfirmation" | i18n }} - - {{ acceptedCount }} - + @if (dataSource.acceptedUserCount; as acceptedCount) { + {{ acceptedCount }} + }
    - +@if (!firstLoaded()) { {{ "loading" | i18n }} - - - -

    {{ "noMembersInList" | i18n }}

    - - - {{ "providerUsersNeedConfirmed" | i18n }} - +} @else { + @if (!dataSource.filteredData?.length) { +

    {{ "noMembersInList" | i18n }}

    + } + @if (dataSource.filteredData?.length) { + @if (showConfirmBanner) { + + {{ "providerUsersNeedConfirmed" | i18n }} + + } @@ -82,27 +85,33 @@ label="{{ 'options' | i18n }}" > + @if (bulkMenuOptions.showBulkReinviteUsers) { + + } + @if (bulkMenuOptions.showBulkConfirmUsers) { + + } - - - - {{ "invited" | i18n }} - - - {{ "needsConfirmation" | i18n }} - - - {{ "revoked" | i18n }} - -
    -
    - {{ user.email }} + @if (user.status === userStatusType.Invited) { + + {{ "invited" | i18n }} + + } + @if (user.status === userStatusType.Accepted) { + + {{ "needsConfirmation" | i18n }} + + } + @if (user.status === userStatusType.Revoked) { + + {{ "revoked" | i18n }} + + }
    + @if (user.name) { +
    + {{ user.email }} +
    + }
    - {{ "providerAdmin" | i18n }} - {{ "serviceUser" | i18n }} + @if (user.type === userType.ProviderAdmin) { + {{ "providerAdmin" | i18n }} + } + @if (user.type === userType.ServiceUser) { + {{ "serviceUser" | i18n }} + } + @if (user.status === userStatusType.Invited) { + + } + @if (user.status === userStatusType.Accepted) { + + } + @if (accessEvents && user.status === userStatusType.Confirmed) { + + } - - - + + `, + imports: [PopoverTriggerForDirective, PopoverComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class TestPopoverTriggerComponent { + isOpen = false; + readonly directive = viewChild("trigger", { read: PopoverTriggerForDirective }); + readonly popoverComponent = viewChild("popoverComponent", { read: PopoverComponent }); + readonly templateRef = viewChild("trigger", { read: TemplateRef }); +} + +describe("PopoverTriggerForDirective", () => { + let fixture: ComponentFixture; + let component: TestPopoverTriggerComponent; + let directive: PopoverTriggerForDirective; + let overlayRef: Partial; + let overlay: Partial; + let ngZone: NgZone; + + beforeEach(async () => { + // Create mock overlay ref + overlayRef = { + backdropElement: document.createElement("div"), + attach: jest.fn(), + detach: jest.fn(), + dispose: jest.fn(), + detachments: jest.fn().mockReturnValue(new Subject()), + keydownEvents: jest.fn().mockReturnValue(new Subject()), + backdropClick: jest.fn().mockReturnValue(new Subject()), + }; + + // Create mock overlay + const mockPositionStrategy = { + flexibleConnectedTo: jest.fn().mockReturnThis(), + withPositions: jest.fn().mockReturnThis(), + withLockedPosition: jest.fn().mockReturnThis(), + withFlexibleDimensions: jest.fn().mockReturnThis(), + withPush: jest.fn().mockReturnThis(), + }; + + overlay = { + create: jest.fn().mockReturnValue(overlayRef), + position: jest.fn().mockReturnValue(mockPositionStrategy), + scrollStrategies: { + reposition: jest.fn().mockReturnValue({}), + } as any, + }; + + await TestBed.configureTestingModule({ + imports: [TestPopoverTriggerComponent], + providers: [{ provide: Overlay, useValue: overlay }], + }).compileComponents(); + + fixture = TestBed.createComponent(TestPopoverTriggerComponent); + component = fixture.componentInstance; + ngZone = TestBed.inject(NgZone); + fixture.detectChanges(); + directive = component.directive()!; + }); + + afterEach(() => { + fixture.destroy(); + }); + + describe("Initial popover open with RAF delay", () => { + it("should use double RAF delay on first open", fakeAsync(() => { + // Spy on requestAnimationFrame to verify it's being called + const rafSpy = jest.spyOn(window, "requestAnimationFrame"); + + // Set popoverOpen signal directly on the directive inside NgZone + ngZone.run(() => { + directive.popoverOpen.set(true); + fixture.detectChanges(); + }); + + // After effect execution, RAF should be scheduled but not executed yet + expect(overlay.create).not.toHaveBeenCalled(); + + // Execute first RAF - tick(16) advances time by one animation frame (16ms) + // This executes the first requestAnimationFrame callback + tick(16); + expect(overlay.create).not.toHaveBeenCalled(); + + // Execute second RAF - the nested requestAnimationFrame callback + tick(16); + expect(overlay.create).toHaveBeenCalled(); + expect(overlayRef.attach).toHaveBeenCalled(); + + rafSpy.mockRestore(); + flush(); + })); + + it("should skip RAF delay on subsequent opens", fakeAsync(() => { + // First open with double RAF delay + ngZone.run(() => { + directive.popoverOpen.set(true); + fixture.detectChanges(); + }); + // Execute both RAF callbacks (16ms each = 32ms total for first open) + tick(16); // First RAF + tick(16); // Second RAF + expect(overlay.create).toHaveBeenCalledTimes(1); + jest.mocked(overlay.create).mockClear(); + + // Close by clicking + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + // Second open should skip RAF delay (hasInitialized is now true) + ngZone.run(() => { + directive.popoverOpen.set(true); + fixture.detectChanges(); + }); + // Only need tick(0) to flush microtasks - NO RAF delay on subsequent opens + tick(0); + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + }); + + describe("Race condition prevention", () => { + it("should prevent multiple RAF scheduling when toggled rapidly", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Try to toggle back to false before RAF completes + ngZone.run(() => { + directive.popoverOpen.set(false); + }); + fixture.detectChanges(); + + // Try to toggle back to true + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Execute RAFs + tick(16); + tick(16); + + // Should only create overlay once + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + + it("should not schedule new RAF if one is already pending", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Try to open again while RAF is pending (shouldn't schedule another) + ngZone.run(() => { + directive.popoverOpen.set(false); + }); + fixture.detectChanges(); + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + tick(16); + tick(16); + + // Should only have created one overlay + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + + it("should prevent duplicate overlays from click handler during RAF", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Click to close before RAF completes - this should cancel the RAF and prevent overlay creation + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + // Verify popoverOpen was set to false + expect(directive.popoverOpen()).toBe(false); + + tick(16); + tick(16); + + // Should NOT have created any overlay because RAF was canceled + expect(overlay.create).not.toHaveBeenCalled(); + + flush(); + })); + }); + + describe("Component destruction during RAF", () => { + it("should cancel RAF callbacks when component is destroyed", fakeAsync(() => { + const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame"); + + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Destroy component before RAF completes + fixture.destroy(); + + // Should have cancelled animation frames + expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + + cancelAnimationFrameSpy.mockRestore(); + + flush(); + })); + + it("should not create overlay if destroyed during RAF delay", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Execute first RAF + tick(16); + + // Destroy before second RAF + fixture.destroy(); + + // Execute second RAF (should be no-op) + tick(16); + + expect(overlay.create).not.toHaveBeenCalled(); + + flush(); + })); + + it("should set isDestroyed flag and prevent further operations", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + // Destroy the component + fixture.destroy(); + + // Try to toggle (should be blocked by isDestroyed check) + const button = fixture.nativeElement.querySelector("button"); + button.click(); + + expect(overlay.create).toHaveBeenCalledTimes(1); // Only from initial open + + flush(); + })); + }); + + describe("Click handling", () => { + it("should open popover on click when closed", fakeAsync(() => { + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + expect(component.isOpen).toBe(true); + expect(overlay.create).toHaveBeenCalled(); + + flush(); + })); + + it("should close popover on click when open", fakeAsync(() => { + // Open first + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + // Click to close + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + expect(component.isOpen).toBe(false); + expect(overlayRef.dispose).toHaveBeenCalled(); + + flush(); + })); + + it("should not process clicks after component is destroyed", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + const initialCreateCount = jest.mocked(overlay.create).mock.calls.length; + + fixture.destroy(); + + const button = fixture.nativeElement.querySelector("button"); + button.click(); + + // Should not have created additional overlay + expect(overlay.create).toHaveBeenCalledTimes(initialCreateCount); + + flush(); + })); + }); + + describe("Resource cleanup", () => { + it("should cancel both RAF IDs in disposeAll", fakeAsync(() => { + const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame"); + + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Trigger disposal while RAF is pending + directive.ngOnDestroy(); + + // Should cancel animation frames + expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + + cancelAnimationFrameSpy.mockRestore(); + + flush(); + })); + + it("should dispose overlay on destroy", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + expect(overlayRef.attach).toHaveBeenCalled(); + + fixture.destroy(); + + expect(overlayRef.dispose).toHaveBeenCalled(); + + flush(); + })); + + it("should unsubscribe from closed events on destroy", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + // Get the subscription (it's private, so we'll verify via disposal) + fixture.destroy(); + + // Should have disposed overlay which triggers cleanup + expect(overlayRef.dispose).toHaveBeenCalled(); + + flush(); + })); + }); + + describe("Overlay guard in openPopover", () => { + it("should not create duplicate overlay if overlayRef already exists", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + expect(overlay.create).toHaveBeenCalledTimes(1); + + // Try to open again + ngZone.run(() => { + directive.popoverOpen.set(false); + }); + fixture.detectChanges(); + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + }); + + describe("aria-expanded attribute", () => { + it("should set aria-expanded to false when closed", () => { + const button = fixture.nativeElement.querySelector("button"); + expect(button.getAttribute("aria-expanded")).toBe("false"); + }); + + it("should set aria-expanded to true when open", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + const button = fixture.nativeElement.querySelector("button"); + expect(button.getAttribute("aria-expanded")).toBe("true"); + + flush(); + })); + }); +}); diff --git a/libs/components/src/popover/popover-trigger-for.directive.ts b/libs/components/src/popover/popover-trigger-for.directive.ts index cb114f1fbc3..176a736fb39 100644 --- a/libs/components/src/popover/popover-trigger-for.directive.ts +++ b/libs/components/src/popover/popover-trigger-for.directive.ts @@ -1,12 +1,12 @@ import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; import { TemplatePortal } from "@angular/cdk/portal"; import { - AfterViewInit, Directive, ElementRef, HostListener, OnDestroy, ViewContainerRef, + effect, input, model, } from "@angular/core"; @@ -22,7 +22,7 @@ import { PopoverComponent } from "./popover.component"; "[attr.aria-expanded]": "this.popoverOpen()", }, }) -export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { +export class PopoverTriggerForDirective implements OnDestroy { readonly popoverOpen = model(false); readonly popover = input.required({ alias: "bitPopoverTriggerFor" }); @@ -31,6 +31,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { private overlayRef: OverlayRef | null = null; private closedEventsSub: Subscription | null = null; + private hasInitialized = false; + private rafId1: number | null = null; + private rafId2: number | null = null; + private isDestroyed = false; get positions() { if (!this.position()) { @@ -65,10 +69,44 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { private elementRef: ElementRef, private viewContainerRef: ViewContainerRef, private overlay: Overlay, - ) {} + ) { + effect(() => { + if (this.isDestroyed || !this.popoverOpen() || this.overlayRef) { + return; + } + + if (this.hasInitialized) { + this.openPopover(); + return; + } + + if (this.rafId1 !== null || this.rafId2 !== null) { + return; + } + + // Initial open - wait for layout to stabilize + // First RAF: Waits for Angular's change detection to complete and queues the next paint + this.rafId1 = requestAnimationFrame(() => { + // Second RAF: Ensures the browser has actually painted that frame and all layout/position calculations are final + this.rafId2 = requestAnimationFrame(() => { + if (this.isDestroyed || !this.popoverOpen() || this.overlayRef) { + return; + } + this.openPopover(); + this.hasInitialized = true; + this.rafId2 = null; + }); + this.rafId1 = null; + }); + }); + } @HostListener("click") togglePopover() { + if (this.isDestroyed) { + return; + } + if (this.popoverOpen()) { this.closePopover(); } else { @@ -77,6 +115,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { } private openPopover() { + if (this.overlayRef) { + return; + } + this.popoverOpen.set(true); this.overlayRef = this.overlay.create(this.defaultPopoverConfig); @@ -104,7 +146,7 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { } private destroyPopover() { - if (!this.overlayRef || !this.popoverOpen()) { + if (!this.popoverOpen()) { return; } @@ -117,15 +159,19 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { this.closedEventsSub = null; this.overlayRef?.dispose(); this.overlayRef = null; - } - ngAfterViewInit() { - if (this.popoverOpen()) { - this.openPopover(); + if (this.rafId1 !== null) { + cancelAnimationFrame(this.rafId1); + this.rafId1 = null; + } + if (this.rafId2 !== null) { + cancelAnimationFrame(this.rafId2); + this.rafId2 = null; } } ngOnDestroy() { + this.isDestroyed = true; this.disposeAll(); } diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts index 08f4d875962..4c602995cd1 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink.stories.ts @@ -13,6 +13,7 @@ import { import { PasswordManagerLogo } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { GlobalStateProvider } from "@bitwarden/state"; import { LayoutComponent } from "../../layout"; @@ -71,6 +72,13 @@ export default { }); }, }, + { + provide: PlatformUtilsService, + useValue: { + // eslint-disable-next-line + copyToClipboard: (text: string) => console.log(`${text} copied to clipboard`), + }, + }, { provide: GlobalStateProvider, useClass: StorybookGlobalStateProvider, diff --git a/libs/components/src/tooltip/tooltip.directive.ts b/libs/components/src/tooltip/tooltip.directive.ts index cca52526c7d..419b503c911 100644 --- a/libs/components/src/tooltip/tooltip.directive.ts +++ b/libs/components/src/tooltip/tooltip.directive.ts @@ -11,6 +11,7 @@ import { signal, model, computed, + OnDestroy, } from "@angular/core"; import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions"; @@ -32,7 +33,7 @@ export const TOOLTIP_DELAY_MS = 800; "[attr.aria-describedby]": "resolvedDescribedByIds()", }, }) -export class TooltipDirective implements OnInit { +export class TooltipDirective implements OnInit, OnDestroy { private static nextId = 0; /** * The value of this input is forwarded to the tooltip.component to render @@ -51,6 +52,7 @@ export class TooltipDirective implements OnInit { private readonly isVisible = signal(false); private overlayRef: OverlayRef | undefined; + private showTimeoutId: ReturnType | undefined; private elementRef = inject>(ElementRef); private overlay = inject(Overlay); private viewContainerRef = inject(ViewContainerRef); @@ -81,13 +83,29 @@ export class TooltipDirective implements OnInit { }), ); + /** + * Clear any pending show timeout + * + * Use cases: prevent tooltip from appearing after hide; clear existing timeout before showing a + * new tooltip + */ + private clearTimeout() { + if (this.showTimeoutId !== undefined) { + clearTimeout(this.showTimeoutId); + this.showTimeoutId = undefined; + } + } + private destroyTooltip = () => { + this.clearTimeout(); this.overlayRef?.dispose(); this.overlayRef = undefined; this.isVisible.set(false); }; protected showTooltip = () => { + this.clearTimeout(); + if (!this.overlayRef) { this.overlayRef = this.overlay.create({ ...this.defaultPopoverConfig, @@ -97,8 +115,9 @@ export class TooltipDirective implements OnInit { this.overlayRef.attach(this.tooltipPortal); } - setTimeout(() => { + this.showTimeoutId = setTimeout(() => { this.isVisible.set(true); + this.showTimeoutId = undefined; }, TOOLTIP_DELAY_MS); }; @@ -134,4 +153,8 @@ export class TooltipDirective implements OnInit { ngOnInit() { this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition())); } + + ngOnDestroy(): void { + this.destroyTooltip(); + } } diff --git a/libs/components/src/tooltip/tooltip.spec.ts b/libs/components/src/tooltip/tooltip.spec.ts index ff73911d29d..b3ec710a294 100644 --- a/libs/components/src/tooltip/tooltip.spec.ts +++ b/libs/components/src/tooltip/tooltip.spec.ts @@ -41,6 +41,7 @@ interface OverlayLike { interface OverlayRefStub { attach: (portal: ComponentPortal) => unknown; updatePosition: () => void; + dispose: () => void; } describe("TooltipDirective (visibility only)", () => { @@ -68,6 +69,7 @@ describe("TooltipDirective (visibility only)", () => { }, })), updatePosition: jest.fn(), + dispose: jest.fn(), }; const overlayMock: OverlayLike = { diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 0ff62b00e78..d58859ac163 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -29,15 +29,15 @@ import { combineLatestWith, filter, map, switchMap, takeUntil } from "rxjs/opera // 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 { - CollectionService, - CollectionTypes, - CollectionView, -} from "@bitwarden/admin-console/common"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; diff --git a/libs/importer/src/importers/base-importer.ts b/libs/importer/src/importers/base-importer.ts index 7196a83783a..a32a53f3e60 100644 --- a/libs/importer/src/importers/base-importer.ts +++ b/libs/importer/src/importers/base-importer.ts @@ -2,9 +2,7 @@ // @ts-strict-ignore import * as papa from "papaparse"; -// 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 { Collection, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView, Collection } from "@bitwarden/common/admin-console/models/collections"; import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; diff --git a/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts index 4771f47b4c9..5d165d9a76d 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts @@ -2,9 +2,7 @@ // @ts-strict-ignore import { filter, firstValueFrom } from "rxjs"; -// 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 { Collection } from "@bitwarden/admin-console/common"; +import { Collection } from "@bitwarden/common/admin-console/models/collections"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; diff --git a/libs/importer/src/importers/padlock-csv-importer.ts b/libs/importer/src/importers/padlock-csv-importer.ts index ec781170c4d..4554cd3a9be 100644 --- a/libs/importer/src/importers/padlock-csv-importer.ts +++ b/libs/importer/src/importers/padlock-csv-importer.ts @@ -1,8 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -// 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 { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { ImportResult } from "../models/import-result"; diff --git a/libs/importer/src/importers/passpack-csv-importer.ts b/libs/importer/src/importers/passpack-csv-importer.ts index 09ff841b8a4..2cc7135f046 100644 --- a/libs/importer/src/importers/passpack-csv-importer.ts +++ b/libs/importer/src/importers/passpack-csv-importer.ts @@ -1,6 +1,4 @@ -// 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 { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { ImportResult } from "../models/import-result"; diff --git a/libs/importer/src/importers/password-depot/password-depot-17-xml-importer.spec.ts b/libs/importer/src/importers/password-depot/password-depot-17-xml-importer.spec.ts index 1f14d05c51e..8b78b33c154 100644 --- a/libs/importer/src/importers/password-depot/password-depot-17-xml-importer.spec.ts +++ b/libs/importer/src/importers/password-depot/password-depot-17-xml-importer.spec.ts @@ -1,6 +1,4 @@ -// 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 { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { FieldType, SecureNoteType } from "@bitwarden/common/vault/enums"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; diff --git a/libs/importer/src/models/import-result.ts b/libs/importer/src/models/import-result.ts index b99068ff83f..cefc80be61d 100644 --- a/libs/importer/src/models/import-result.ts +++ b/libs/importer/src/models/import-result.ts @@ -1,8 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -// 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 { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; diff --git a/libs/importer/src/services/import-collection.service.abstraction.ts b/libs/importer/src/services/import-collection.service.abstraction.ts index f74d556b897..1dae6411ac8 100644 --- a/libs/importer/src/services/import-collection.service.abstraction.ts +++ b/libs/importer/src/services/import-collection.service.abstraction.ts @@ -1,8 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -// 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 { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { UserId } from "@bitwarden/user-core"; export abstract class ImportCollectionServiceAbstraction { diff --git a/libs/importer/src/services/import.service.abstraction.ts b/libs/importer/src/services/import.service.abstraction.ts index d8f1f6ccd5c..51867212689 100644 --- a/libs/importer/src/services/import.service.abstraction.ts +++ b/libs/importer/src/services/import.service.abstraction.ts @@ -1,9 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore - -// 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 { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { Importer } from "../importers/importer"; diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index b82772669de..33a1e47a4ce 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -2,11 +2,11 @@ import { mock, MockProxy } from "jest-mock-extended"; // 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 { CollectionService } from "@bitwarden/admin-console/common"; import { - CollectionService, - CollectionTypes, CollectionView, -} from "@bitwarden/admin-console/common"; + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 829bd04e994..bd0a9eb0d4d 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -4,12 +4,11 @@ import { firstValueFrom, map } from "rxjs"; // 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 { CollectionService, CollectionWithIdRequest } from "@bitwarden/admin-console/common"; import { - CollectionService, - CollectionWithIdRequest, CollectionView, CollectionTypes, -} from "@bitwarden/admin-console/common"; +} from "@bitwarden/common/admin-console/models/collections"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index 6cf44544422..db8eacb5fa4 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -128,18 +128,13 @@ export abstract class KeyService { /** * Generates a new user key - * @deprecated Interacting with the master key directly is prohibited. Use {@link makeUserKeyV1} instead. + * @deprecated Interacting with the master key directly is prohibited. + * For new features please use the KM provided SDK methods for user cryptography initialization or reach out to the KM team. * @throws Error when master key is null or undefined. * @param masterKey The user's master key. * @returns A new user key and the master key protected version of it */ abstract makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]>; - /** - * Generates a new user key for a V1 user - * Note: This will be replaced by a higher level function to initialize a whole users cryptographic state in the near future. - * @returns A new user key - */ - abstract makeUserKeyV1(): Promise; /** * Clears the user's stored version of the user key * @param userId The desired user @@ -285,8 +280,7 @@ export abstract class KeyService { * encrypted private key at all. * * @param userId The user id of the user to get the data for. - * @returns An observable stream of the decrypted private key or null. - * @throws Error when decryption of the encrypted private key fails. + * @returns An observable stream of the decrypted private key or null if the private key is not present or fails to decrypt */ abstract userPrivateKey$(userId: UserId): Observable; @@ -334,9 +328,9 @@ export abstract class KeyService { abstract getFingerprint(fingerprintMaterial: string, publicKey: Uint8Array): Promise; /** * Generates a new keypair - * @param key A key to encrypt the private key with. If not provided, - * defaults to the user key - * @returns A new keypair: [publicKey in Base64, encrypted privateKey] + * @deprecated New use-cases of this function are prohibited. Low-level cryptographic constructions and initialization should be done in the SDK. + * @param key A symmetric key to wrap the newly created private key with. + * @returns A new keypair: [publicKey in Base64, wrapped privateKey] * @throws If the provided key is a null-ish value. */ abstract makeKeyPair(key: SymmetricCryptoKey): Promise<[string, EncString]>; @@ -361,6 +355,8 @@ export abstract class KeyService { /** * Initialize all necessary crypto keys needed for a new account. * Warning! This completely replaces any existing keys! + * @deprecated New use cases for cryptography initialization should be done in the SDK. + * Current usage is actively being migrated see PM-21771 for details. * @param userId The user id of the target user. * @returns The user's newly created public key, private key, and encrypted private key * @throws An error if the userId is null or undefined. diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index c0a0ab62347..9d96d7c09b1 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -437,14 +437,13 @@ describe("keyService", () => { ); }); - it("throws an error if unwrapping encrypted private key fails", async () => { + it("emits null if unwrapping encrypted private key fails", async () => { encryptService.unwrapDecapsulationKey.mockImplementationOnce(() => { throw new Error("Unwrapping failed"); }); - await expect(firstValueFrom(keyService.userPrivateKey$(mockUserId))).rejects.toThrow( - "Unwrapping failed", - ); + const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId)); + expect(result).toBeNull(); }); it("returns null if user key is not set", async () => { diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 8cb072a4c2a..4c749e9f6c4 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -213,11 +213,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { return this.buildProtectedSymmetricKey(masterKey, newUserKey); } - async makeUserKeyV1(): Promise { - const newUserKey = await this.keyGenerationService.createKey(512); - return newUserKey as UserKey; - } - /** * Clears the user key. Clears all stored versions of the user keys as well, such as the biometrics key * @param userId The desired user @@ -796,7 +791,10 @@ export class DefaultKeyService implements KeyServiceAbstraction { return this.stateProvider.getUser(userId, USER_ENCRYPTED_PRIVATE_KEY).state$; } - private userPrivateKeyHelper$(userId: UserId) { + private userPrivateKeyHelper$(userId: UserId): Observable<{ + userKey: UserKey; + userPrivateKey: UserPrivateKey | null; + } | null> { const userKey$ = this.userKey$(userId); return userKey$.pipe( switchMap((userKey) => { @@ -806,18 +804,20 @@ export class DefaultKeyService implements KeyServiceAbstraction { return this.stateProvider.getUser(userId, USER_ENCRYPTED_PRIVATE_KEY).state$.pipe( switchMap(async (encryptedPrivateKey) => { - try { - return await this.decryptPrivateKey(encryptedPrivateKey, userKey); - } catch (e) { - this.logService.error("Failed to decrypt private key for user ", userId, e); - throw e; - } + return await this.decryptPrivateKey(encryptedPrivateKey, userKey); }), // Combine outerscope info with user private key map((userPrivateKey) => ({ userKey, userPrivateKey, })), + catchError((err: unknown) => { + this.logService.error(`Failed to decrypt private key for user ${userId}`); + return of({ + userKey, + userPrivateKey: null, + }); + }), ); }), ); diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts index 58620f49ed1..469ed2a00d0 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts @@ -12,6 +12,8 @@ export abstract class UserAsymmetricKeysRegenerationService { * Performs the regeneration of the user's public/private key pair without checking any preconditions. * This should only be used for V1 encryption accounts * @param userId The user id. + * @returns True if regeneration was performed, false otherwise. + * @throws An error if the regeneration could not be attempted due to missing state */ - abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise; + abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise; } diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts index 36bf9c8a421..f26f5cad6a4 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts @@ -123,7 +123,7 @@ export class DefaultUserAsymmetricKeysRegenerationService implements UserAsymmet return false; } - async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise { + async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise { const userKey = await firstValueFrom(this.keyService.userKey$(userId)); if (userKey == null) { throw new Error("User key not found"); @@ -152,19 +152,21 @@ export class DefaultUserAsymmetricKeysRegenerationService implements UserAsymmet this.logService.info( "[UserAsymmetricKeyRegeneration] Regeneration not supported for this user at this time.", ); + return false; } else { this.logService.error( "[UserAsymmetricKeyRegeneration] Regeneration error when submitting the request to the server: " + error, ); + return false; } - return; } await this.keyService.setPrivateKey(makeKeyPairResponse.userKeyEncryptedPrivateKey, userId); this.logService.info( "[UserAsymmetricKeyRegeneration] User's asymmetric keys successfully regenerated.", ); + return true; } private async userKeyCanDecrypt(userKey: UserKey, userId: UserId): Promise { diff --git a/libs/nx-plugin/src/generators/basic-lib.spec.ts b/libs/nx-plugin/src/generators/basic-lib.spec.ts index 9fd7a702375..2018593046b 100644 --- a/libs/nx-plugin/src/generators/basic-lib.spec.ts +++ b/libs/nx-plugin/src/generators/basic-lib.spec.ts @@ -24,7 +24,7 @@ describe("basic-lib generator", () => { expect(tsconfigContent).not.toBeNull(); const tsconfig = JSON.parse(tsconfigContent?.toString() ?? ""); expect(tsconfig.compilerOptions.paths[`@bitwarden/${options.name}`]).toEqual([ - `libs/test/src/index.ts`, + `./libs/test/src/index.ts`, ]); }); diff --git a/libs/nx-plugin/src/generators/basic-lib.ts b/libs/nx-plugin/src/generators/basic-lib.ts index 4f2f542ac89..c0d8a528841 100644 --- a/libs/nx-plugin/src/generators/basic-lib.ts +++ b/libs/nx-plugin/src/generators/basic-lib.ts @@ -82,7 +82,7 @@ function updateTsConfigPath(tree: Tree, name: string, srcRoot: string) { updateJson(tree, "tsconfig.base.json", (json) => { const paths = json.compilerOptions.paths || {}; - paths[`@bitwarden/${name}`] = [`${srcRoot}/index.ts`]; + paths[`@bitwarden/${name}`] = [`./${srcRoot}/index.ts`]; json.compilerOptions.paths = paths; return json; diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index e2fe7d80dc0..e916de3995d 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -46,7 +46,7 @@
    @let passwordManagerSeats = cart.passwordManager.seats;
    - {{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.name | i18n }} x + {{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.translationKey | i18n }} x {{ passwordManagerSeats.cost | currency: "USD" : "symbol" }} / {{ term }} @@ -63,7 +63,7 @@
    - {{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x + {{ additionalStorage.quantity }} {{ additionalStorage.translationKey | i18n }} x {{ additionalStorage.cost | currency: "USD" : "symbol" }} / {{ term }}
    @@ -86,7 +86,7 @@
    - {{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.name | i18n }} x + {{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.translationKey | i18n }} x {{ secretsManagerSeats.cost | currency: "USD" : "symbol" }} / {{ term }}
    @@ -105,7 +105,7 @@
    {{ additionalServiceAccounts.quantity }} - {{ additionalServiceAccounts.name | i18n }} x + {{ additionalServiceAccounts.translationKey | i18n }} x {{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }} / {{ term }} diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx index 02e705276bc..d327d5658fe 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.mdx +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.mdx @@ -67,7 +67,7 @@ The component uses the following Cart and CartItem data structures: ```typescript export type CartItem = { - name: string; // Display name for i18n lookup + translationKey: string; // Translation key for i18n lookup quantity: number; // Number of items cost: number; // Cost per item discount?: Discount; // Optional item-level discount @@ -92,7 +92,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing"; export type Discount = { type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff - active: boolean; // Whether discount is currently applied value: number; // Dollar amount or percentage (20 for 20%) }; ``` @@ -108,7 +107,7 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, @@ -124,12 +123,12 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, @@ -145,14 +144,13 @@ The cart summary component provides flexibility through its structured Cart inpu passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, cadence: 'monthly', discount: { type: 'percent-off', - active: true, value: 20 }, estimatedTax: 8.00 @@ -188,7 +186,7 @@ Show cart with yearly subscription: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 500.00 } }, @@ -211,12 +209,12 @@ Show cart with password manager and additional storage: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, @@ -239,14 +237,14 @@ Show cart with password manager and secrets manager seats only: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 } }, @@ -269,19 +267,19 @@ Show cart with password manager, secrets manager seats, and additional service a passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 }, additionalServiceAccounts: { quantity: 2, - name: 'additionalServiceAccounts', + translationKey: 'additionalServiceAccounts', cost: 6.00 } }, @@ -304,24 +302,24 @@ Show a cart with all available products: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 }, additionalServiceAccounts: { quantity: 2, - name: 'additionalServiceAccounts', + translationKey: 'additionalServiceAccounts', cost: 6.00 } }, @@ -344,19 +342,18 @@ Show cart with percentage-based discount: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 }, additionalStorage: { quantity: 2, - name: 'additionalStorageGB', + translationKey: 'additionalStorageGB', cost: 10.00 } }, cadence: 'monthly', discount: { type: 'percent-off', - active: true, value: 20 }, estimatedTax: 10.40 @@ -377,21 +374,20 @@ Show cart with fixed amount discount: passwordManager: { seats: { quantity: 5, - name: 'members', + translationKey: 'members', cost: 50.00 } }, secretsManager: { seats: { quantity: 3, - name: 'members', + translationKey: 'members', cost: 30.00 } }, cadence: 'annually', discount: { type: 'amount-off', - active: true, value: 50.00 }, estimatedTax: 95.00 @@ -431,7 +427,7 @@ Show cart with premium plan: passwordManager: { seats: { quantity: 1, - name: 'premiumMembership', + translationKey: 'premiumMembership', cost: 10.00 } }, @@ -454,7 +450,7 @@ Show cart with families plan: passwordManager: { seats: { quantity: 1, - name: 'familiesMembership', + translationKey: 'familiesMembership', cost: 40.00 } }, @@ -488,8 +484,7 @@ Show cart with families plan: - Use consistent naming and formatting for cart items - Include clear quantity and unit pricing information - Ensure tax estimates are accurate and clearly labeled -- Set `active: true` on discounts that should be displayed -- Use localized strings for CartItem names (for i18n lookup) +- Use valid translation keys for CartItem translationKey (for i18n lookup) - Provide complete Cart object with all required fields - Use "annually" or "monthly" for cadence (not "year" or "month") diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts index f019322e4db..10975585899 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.spec.ts @@ -16,24 +16,24 @@ describe("CartSummaryComponent", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10, }, }, secretsManager: { seats: { quantity: 3, - name: "secretsManagerSeats", + translationKey: "secretsManagerSeats", cost: 30, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6, }, }, @@ -270,7 +270,6 @@ describe("CartSummaryComponent", () => { ...mockCart, discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, }; @@ -296,7 +295,6 @@ describe("CartSummaryComponent", () => { ...mockCart, discount: { type: DiscountTypes.AmountOff, - active: true, value: 50.0, }, }; @@ -315,33 +313,12 @@ describe("CartSummaryComponent", () => { expect(discountAmount.nativeElement.textContent).toContain("-$50.00"); }); - it("should not display discount when discount is inactive", () => { - // Arrange - const cartWithInactiveDiscount: Cart = { - ...mockCart, - discount: { - type: DiscountTypes.PercentOff, - active: false, - value: 20, - }, - }; - fixture.componentRef.setInput("cart", cartWithInactiveDiscount); - fixture.detectChanges(); - - // Act / Assert - const discountSection = fixture.debugElement.query( - By.css('[data-testid="discount-section"]'), - ); - expect(discountSection).toBeFalsy(); - }); - it("should apply discount to total calculation", () => { // Arrange const cartWithDiscount: Cart = { ...mockCart, discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, }; @@ -382,24 +359,24 @@ describe("CartSummaryComponent - Custom Header Template", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10, }, }, secretsManager: { seats: { quantity: 3, - name: "secretsManagerSeats", + translationKey: "secretsManagerSeats", cost: 30, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6, }, }, diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts index aed23c54a30..581e363ab24 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.stories.ts @@ -71,7 +71,7 @@ export default { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, @@ -98,12 +98,12 @@ export const WithAdditionalStorage: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, @@ -120,7 +120,7 @@ export const PasswordManagerYearlyCadence: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 500.0, }, }, @@ -137,14 +137,14 @@ export const SecretsManagerSeatsOnly: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, }, @@ -161,19 +161,19 @@ export const SecretsManagerSeatsAndServiceAccounts: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6.0, }, }, @@ -190,24 +190,24 @@ export const AllProducts: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, additionalServiceAccounts: { quantity: 2, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 6.0, }, }, @@ -223,7 +223,7 @@ export const FamiliesPlan: Story = { passwordManager: { seats: { quantity: 1, - name: "familiesMembership", + translationKey: "familiesMembership", cost: 40.0, }, }, @@ -239,7 +239,7 @@ export const PremiumPlan: Story = { passwordManager: { seats: { quantity: 1, - name: "premiumMembership", + translationKey: "premiumMembership", cost: 10.0, }, }, @@ -255,7 +255,7 @@ export const CustomHeaderTemplate: Story = { passwordManager: { seats: { quantity: 1, - name: "premiumMembership", + translationKey: "premiumMembership", cost: 10.0, }, }, @@ -296,19 +296,18 @@ export const WithPercentDiscount: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 10.0, }, }, cadence: "monthly", discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, }, estimatedTax: 10.4, @@ -322,21 +321,20 @@ export const WithAmountDiscount: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50.0, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 30.0, }, }, cadence: "annually", discount: { type: DiscountTypes.AmountOff, - active: true, value: 50.0, }, estimatedTax: 95.0, diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.ts b/libs/pricing/src/components/cart-summary/cart-summary.component.ts index b92a465169c..ef35f0ded33 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.ts +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.ts @@ -116,7 +116,7 @@ export class CartSummaryComponent { */ readonly discountAmount = computed(() => { const { discount } = this.cart(); - if (!discount || !discount.active) { + if (!discount) { return 0; } @@ -136,7 +136,7 @@ export class CartSummaryComponent { */ readonly discountLabel = computed(() => { const { discount } = this.cart(); - if (!discount || !discount.active) { + if (!discount) { return ""; } return getLabel(this.i18nService, discount); diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.mdx b/libs/pricing/src/components/discount-badge/discount-badge.component.mdx index f9b9ba85619..8988f79ea07 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.mdx +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.mdx @@ -38,8 +38,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing"; type Discount = { /** The type of discount */ type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff - /** Whether the discount is currently active */ - active: boolean; /** The discount value (percentage or amount depending on type) */ value: number; }; @@ -47,8 +45,7 @@ type Discount = { ## Behavior -- The badge is only displayed when `discount` is provided, `active` is `true`, and `value` is - greater than 0. +- The badge is only displayed when `discount` is provided and `value` is greater than 0. - For `percent-off` type: percentage values can be provided as 0-100 (e.g., `20` for 20%) or 0-1 (e.g., `0.2` for 20%). - For `amount-off` type: amount values are formatted as currency (USD) with 2 decimal places. @@ -62,7 +59,3 @@ type Discount = { ### Amount Discount - -### Inactive Discount - - diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts index 6f8e7ab9e74..540ae48adb4 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.spec.ts @@ -35,30 +35,18 @@ describe("DiscountBadgeComponent", () => { expect(component.display()).toBe(false); }); - it("should return false when discount is inactive", () => { + it("should return true when discount has percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: false, - value: 20, - }); - fixture.detectChanges(); - expect(component.display()).toBe(false); - }); - - it("should return true when discount is active with percent-off", () => { - fixture.componentRef.setInput("discount", { - type: DiscountTypes.PercentOff, - active: true, value: 20, }); fixture.detectChanges(); expect(component.display()).toBe(true); }); - it("should return true when discount is active with amount-off", () => { + it("should return true when discount has amount-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 10.99, }); fixture.detectChanges(); @@ -68,7 +56,6 @@ describe("DiscountBadgeComponent", () => { it("should return false when value is 0 (percent-off)", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 0, }); fixture.detectChanges(); @@ -78,7 +65,6 @@ describe("DiscountBadgeComponent", () => { it("should return false when value is 0 (amount-off)", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 0, }); fixture.detectChanges(); @@ -96,7 +82,6 @@ describe("DiscountBadgeComponent", () => { it("should return percentage text when type is percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 20, }); fixture.detectChanges(); @@ -108,7 +93,6 @@ describe("DiscountBadgeComponent", () => { it("should convert decimal value to percentage for percent-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.PercentOff, - active: true, value: 0.15, }); fixture.detectChanges(); @@ -119,7 +103,6 @@ describe("DiscountBadgeComponent", () => { it("should return amount text when type is amount-off", () => { fixture.componentRef.setInput("discount", { type: DiscountTypes.AmountOff, - active: true, value: 10.99, }); fixture.detectChanges(); diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts index 1d2d15e84c5..610e7b815a8 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.stories.ts @@ -40,7 +40,6 @@ export const PercentDiscount: Story = { args: { discount: { type: DiscountTypes.PercentOff, - active: true, value: 20, } as Discount, }, @@ -54,7 +53,6 @@ export const PercentDiscountDecimal: Story = { args: { discount: { type: DiscountTypes.PercentOff, - active: true, value: 0.15, // 15% in decimal format } as Discount, }, @@ -68,7 +66,6 @@ export const AmountDiscount: Story = { args: { discount: { type: DiscountTypes.AmountOff, - active: true, value: 10.99, } as Discount, }, @@ -82,26 +79,11 @@ export const LargeAmountDiscount: Story = { args: { discount: { type: DiscountTypes.AmountOff, - active: true, value: 99.99, } as Discount, }, }; -export const InactiveDiscount: Story = { - render: (args) => ({ - props: args, - template: ``, - }), - args: { - discount: { - type: DiscountTypes.PercentOff, - active: false, - value: 20, - } as Discount, - }, -}; - export const NoDiscount: Story = { render: (args) => ({ props: args, diff --git a/libs/pricing/src/components/discount-badge/discount-badge.component.ts b/libs/pricing/src/components/discount-badge/discount-badge.component.ts index 17204be85ff..8937ea274d4 100644 --- a/libs/pricing/src/components/discount-badge/discount-badge.component.ts +++ b/libs/pricing/src/components/discount-badge/discount-badge.component.ts @@ -23,7 +23,7 @@ export class DiscountBadgeComponent { if (!discount) { return false; } - return discount.active && discount.value > 0; + return discount.value > 0; }); readonly label = computed>(() => { diff --git a/libs/pricing/src/types/cart.ts b/libs/pricing/src/types/cart.ts index d27a867b785..ed5108edee8 100644 --- a/libs/pricing/src/types/cart.ts +++ b/libs/pricing/src/types/cart.ts @@ -1,7 +1,7 @@ import { Discount } from "@bitwarden/pricing"; export type CartItem = { - name: string; + translationKey: string; quantity: number; cost: number; discount?: Discount; diff --git a/libs/pricing/src/types/discount.ts b/libs/pricing/src/types/discount.ts index c12998ef609..afea56fce0a 100644 --- a/libs/pricing/src/types/discount.ts +++ b/libs/pricing/src/types/discount.ts @@ -9,7 +9,6 @@ export type DiscountType = (typeof DiscountTypes)[keyof typeof DiscountTypes]; export type Discount = { type: DiscountType; - active: boolean; value: number; }; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.html b/libs/subscription/src/components/additional-options-card/additional-options-card.component.html index 851ae32ddb3..c4d3d291b26 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.html +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.html @@ -13,8 +13,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('download-license')" + [disabled]="downloadLicenseDisabled()" + (click)="callToActionClicked.emit(actions.DownloadLicense)" > {{ "downloadLicense" | i18n }} @@ -22,8 +22,8 @@ bitButton buttonType="danger" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('cancel-subscription')" + [disabled]="cancelSubscriptionDisabled()" + (click)="callToActionClicked.emit(actions.CancelSubscription)" > {{ "cancelSubscription" | i18n }} diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx index 4519d19a530..3162e740cb0 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx @@ -21,6 +21,8 @@ subscription actions. - [Examples](#examples) - [Default](#default) - [Actions Disabled](#actions-disabled) + - [Download License Disabled](#download-license-disabled) + - [Cancel Subscription Disabled](#cancel-subscription-disabled) - [Features](#features) - [Do's and Don'ts](#dos-and-donts) - [Accessibility](#accessibility) @@ -44,9 +46,10 @@ import { AdditionalOptionsCardComponent } from "@bitwarden/subscription"; ### Inputs -| Input | Type | Description | -| ----------------------- | --------- | ---------------------------------------------------------------------- | -| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | +| Input | Type | Description | +| ---------------------------- | --------- | ----------------------------------------------------------------------------- | +| `downloadLicenseDisabled` | `boolean` | Optional. Disables download license button when true. Defaults to `false`. | +| `cancelSubscriptionDisabled` | `boolean` | Optional. Disables cancel subscription button when true. Defaults to `false`. | ### Outputs @@ -109,14 +112,46 @@ Component with action buttons disabled (useful during async operations): ```html ``` -**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like -downloading the license or processing subscription cancellation. +**Note:** Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` independently to control +button states during async operations like downloading the license or processing subscription +cancellation. + +### Download License Disabled + +Component with only the download license button disabled: + + + +```html + + +``` + +### Cancel Subscription Disabled + +Component with only the cancel subscription button disabled: + + + +```html + + +``` ## Features @@ -133,9 +168,11 @@ downloading the license or processing subscription cancellation. - Handle both `download-license` and `cancel-subscription` events in parent components - Show appropriate confirmation dialogs before executing destructive actions (cancel subscription) -- Disable buttons or show loading states during async operations +- Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` to control button states during + operations - Provide clear user feedback after action completion - Consider adding additional safety measures for subscription cancellation +- Control button states independently based on business logic ### ❌ Don't diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts index 345de037fd3..3346c287beb 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts @@ -66,9 +66,32 @@ describe("AdditionalOptionsCardComponent", () => { }); }); - describe("callsToActionDisabled", () => { - it("should disable both buttons when callsToActionDisabled is true", () => { - fixture.componentRef.setInput("callsToActionDisabled", true); + describe("button disabled states", () => { + it("should enable both buttons by default", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + + it("should disable download license button when downloadLicenseDisabled is true", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + }); + + it("should disable cancel subscription button when cancelSubscriptionDisabled is true", () => { + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should disable both buttons independently", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -76,18 +99,23 @@ describe("AdditionalOptionsCardComponent", () => { expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons when callsToActionDisabled is false", () => { - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should allow download enabled while cancel disabled", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", false); + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); expect(buttons[0].nativeElement.disabled).toBe(false); - expect(buttons[1].nativeElement.disabled).toBe(false); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons by default", () => { + it("should allow cancel enabled while download disabled", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.componentRef.setInput("cancelSubscriptionDisabled", false); + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css("button")); - expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); expect(buttons[1].nativeElement.disabled).toBe(false); }); }); diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts index 66c151f536f..7dd7a5375fe 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts @@ -44,6 +44,23 @@ export const Default: Story = { export const ActionsDisabled: Story = { name: "Actions Disabled", args: { - callsToActionDisabled: true, + downloadLicenseDisabled: true, + cancelSubscriptionDisabled: true, + }, +}; + +export const DownloadLicenseDisabled: Story = { + name: "Download License Disabled", + args: { + downloadLicenseDisabled: true, + cancelSubscriptionDisabled: false, + }, +}; + +export const CancelSubscriptionDisabled: Story = { + name: "Cancel Subscription Disabled", + args: { + downloadLicenseDisabled: false, + cancelSubscriptionDisabled: true, }, }; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts index a962a167ec6..6c633a43d93 100644 --- a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts @@ -3,7 +3,13 @@ import { Component, ChangeDetectionStrategy, output, input } from "@angular/core import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -export type AdditionalOptionsCardAction = "download-license" | "cancel-subscription"; +export const AdditionalOptionsCardActions = { + DownloadLicense: "download-license", + CancelSubscription: "cancel-subscription", +} as const; + +export type AdditionalOptionsCardAction = + (typeof AdditionalOptionsCardActions)[keyof typeof AdditionalOptionsCardActions]; @Component({ selector: "billing-additional-options-card", @@ -12,6 +18,10 @@ export type AdditionalOptionsCardAction = "download-license" | "cancel-subscript imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe], }) export class AdditionalOptionsCardComponent { - readonly callsToActionDisabled = input(false); + readonly downloadLicenseDisabled = input(false); + readonly cancelSubscriptionDisabled = input(false); + readonly callToActionClicked = output(); + + protected readonly actions = AdditionalOptionsCardActions; } diff --git a/libs/subscription/src/components/storage-card/storage-card.component.html b/libs/subscription/src/components/storage-card/storage-card.component.html index c11f1917176..f8ac4b18604 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.html +++ b/libs/subscription/src/components/storage-card/storage-card.component.html @@ -21,8 +21,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled()" - (click)="callToActionClicked.emit('add-storage')" + [disabled]="addStorageDisabled()" + (click)="callToActionClicked.emit(actions.AddStorage)" > {{ "addStorage" | i18n }} @@ -30,8 +30,8 @@ bitButton buttonType="secondary" type="button" - [disabled]="callsToActionDisabled() || !canRemoveStorage()" - (click)="callToActionClicked.emit('remove-storage')" + [disabled]="removeStorageDisabled()" + (click)="callToActionClicked.emit(actions.RemoveStorage)" > {{ "removeStorage" | i18n }} diff --git a/libs/subscription/src/components/storage-card/storage-card.component.mdx b/libs/subscription/src/components/storage-card/storage-card.component.mdx index 43215cb863c..7e06fa23553 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.mdx +++ b/libs/subscription/src/components/storage-card/storage-card.component.mdx @@ -30,6 +30,8 @@ full). - [Large Storage Pool (1TB)](#large-storage-pool-1tb) - [Small Storage Pool (1GB)](#small-storage-pool-1gb) - [Actions Disabled](#actions-disabled) + - [Add Storage Disabled](#add-storage-disabled) + - [Remove Storage Disabled](#remove-storage-disabled) - [Features](#features) - [Do's and Don'ts](#dos-and-donts) - [Accessibility](#accessibility) @@ -53,10 +55,11 @@ import { StorageCardComponent, Storage } from "@bitwarden/subscription"; ### Inputs -| Input | Type | Description | -| ----------------------- | --------- | ---------------------------------------------------------------------- | -| `storage` | `Storage` | **Required.** Storage data including available, used, and readable | -| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | +| Input | Type | Description | +| ----------------------- | --------- | ------------------------------------------------------------------------ | +| `storage` | `Storage` | **Required.** Storage data including available, used, and readable | +| `addStorageDisabled` | `boolean` | Optional. Disables add storage button when true. Defaults to `false`. | +| `removeStorageDisabled` | `boolean` | Optional. Disables remove storage button when true. Defaults to `false`. | ### Outputs @@ -93,7 +96,8 @@ The component automatically adapts its appearance based on storage usage: Key behaviors: - Progress bar color changes from blue (primary) to red (danger) when full -- Remove storage button is disabled when storage is full +- Button disabled states are controlled independently via `addStorageDisabled` and + `removeStorageDisabled` inputs - Title changes to "Storage full" when at capacity - Description provides context-specific messaging @@ -123,7 +127,7 @@ Storage with no files uploaded: [storage]="{ available: 5, used: 0, - readableUsed: '0 GB' + readableUsed: '0 GB', }" (callToActionClicked)="handleAction($event)" > @@ -141,7 +145,7 @@ Storage with partial usage (50%): [storage]="{ available: 5, used: 2.5, - readableUsed: '2.5 GB' + readableUsed: '2.5 GB', }" (callToActionClicked)="handleAction($event)" > @@ -159,15 +163,15 @@ Storage at full capacity with disabled remove button: [storage]="{ available: 5, used: 5, - readableUsed: '5 GB' + readableUsed: '5 GB', }" (callToActionClicked)="handleAction($event)" > ``` -**Note:** When storage is full, the "Remove storage" button is disabled and the progress bar turns -red. +**Note:** When storage is full, the progress bar turns red. Button disabled states are controlled +independently via the `addStorageDisabled` and `removeStorageDisabled` inputs. ### Low Usage (10%) @@ -180,7 +184,7 @@ Minimal storage usage: [storage]="{ available: 5, used: 0.5, - readableUsed: '500 MB' + readableUsed: '500 MB', }" (callToActionClicked)="handleAction($event)" > @@ -198,7 +202,7 @@ Substantial storage usage: [storage]="{ available: 5, used: 3.75, - readableUsed: '3.75 GB' + readableUsed: '3.75 GB', }" (callToActionClicked)="handleAction($event)" > @@ -216,7 +220,7 @@ Storage approaching capacity: [storage]="{ available: 5, used: 4.75, - readableUsed: '4.75 GB' + readableUsed: '4.75 GB', }" (callToActionClicked)="handleAction($event)" > @@ -234,7 +238,7 @@ Enterprise-level storage allocation: [storage]="{ available: 1000, used: 734, - readableUsed: '734 GB' + readableUsed: '734 GB', }" (callToActionClicked)="handleAction($event)" > @@ -252,7 +256,7 @@ Minimal storage allocation: [storage]="{ available: 1, used: 0.8, - readableUsed: '800 MB' + readableUsed: '800 MB', }" (callToActionClicked)="handleAction($event)" > @@ -270,16 +274,57 @@ Storage card with action buttons disabled (useful during async operations): [storage]="{ available: 5, used: 2.5, - readableUsed: '2.5 GB' + readableUsed: '2.5 GB', }" - [callsToActionDisabled]="true" + [addStorageDisabled]="true" + [removeStorageDisabled]="true" (callToActionClicked)="handleAction($event)" > ``` -**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like -adding or removing storage. +**Note:** Use `addStorageDisabled` and `removeStorageDisabled` independently to control button +states during async operations like adding or removing storage. + +### Add Storage Disabled + +Storage card with only the add button disabled: + + + +```html + + +``` + +### Remove Storage Disabled + +Storage card with only the remove button disabled: + + + +```html + + +``` ## Features @@ -304,13 +349,14 @@ adding or removing storage. - Use human-readable format strings (e.g., "2.5 GB", "500 MB") for `readableUsed` - Keep `used` value less than or equal to `available` under normal circumstances - Update storage data in real-time when user adds or removes storage -- Disable UI interactions when storage operations are in progress +- Use `addStorageDisabled` and `removeStorageDisabled` to control button states during operations - Show loading states during async storage operations +- Control button states independently based on business logic ### ❌ Don't -- Omit the `readableUsed` field - it's required for display -- Use inconsistent units between `available` and `used` (both should be in GB) +- Omit the `readableUsed` field - it's required +- Use inconsistent units between `available` and `used` (all should be in GB) - Allow negative values for storage amounts - Ignore the `callToActionClicked` events - they require handling - Display inaccurate or stale storage information diff --git a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts index ae0d7ad9dcb..fe2223f1449 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts @@ -163,18 +163,6 @@ describe("StorageCardComponent", () => { }); }); - describe("canRemoveStorage", () => { - it("should return true when storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); - expect(component.canRemoveStorage()).toBe(true); - }); - - it("should return false when storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); - expect(component.canRemoveStorage()).toBe(false); - }); - }); - describe("button rendering", () => { it("should render both buttons", () => { setupComponent(baseStorage); @@ -182,25 +170,46 @@ describe("StorageCardComponent", () => { expect(buttons.length).toBe(2); }); - it("should enable remove button when storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + it("should enable add button by default", () => { + setupComponent(baseStorage); + const buttons = fixture.debugElement.queryAll(By.css("button")); + const addButton = buttons[0].nativeElement; + expect(addButton.disabled).toBe(false); + }); + + it("should disable add button when addStorageDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + const addButton = buttons[0]; + expect(addButton.attributes["aria-disabled"]).toBe("true"); + }); + + it("should enable remove button by default", () => { + setupComponent(baseStorage); const buttons = fixture.debugElement.queryAll(By.css("button")); const removeButton = buttons[1].nativeElement; expect(removeButton.disabled).toBe(false); }); - it("should disable remove button when storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + it("should disable remove button when removeStorageDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("removeStorageDisabled", true); + fixture.detectChanges(); + const buttons = fixture.debugElement.queryAll(By.css("button")); const removeButton = buttons[1]; expect(removeButton.attributes["aria-disabled"]).toBe("true"); }); }); - describe("callsToActionDisabled", () => { - it("should disable both buttons when callsToActionDisabled is true", () => { + describe("independent button disabled states", () => { + it("should disable both buttons independently", () => { setupComponent(baseStorage); - fixture.componentRef.setInput("callsToActionDisabled", true); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.componentRef.setInput("removeStorageDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -208,9 +217,10 @@ describe("StorageCardComponent", () => { expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); - it("should enable both buttons when callsToActionDisabled is false and storage is not full", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should enable both buttons when both disabled inputs are false", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", false); + fixture.componentRef.setInput("removeStorageDisabled", false); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); @@ -218,15 +228,27 @@ describe("StorageCardComponent", () => { expect(buttons[1].nativeElement.disabled).toBe(false); }); - it("should keep remove button disabled when callsToActionDisabled is false but storage is full", () => { - setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); - fixture.componentRef.setInput("callsToActionDisabled", false); + it("should allow add button enabled while remove button disabled", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", false); + fixture.componentRef.setInput("removeStorageDisabled", true); fixture.detectChanges(); const buttons = fixture.debugElement.queryAll(By.css("button")); expect(buttons[0].nativeElement.disabled).toBe(false); expect(buttons[1].attributes["aria-disabled"]).toBe("true"); }); + + it("should allow remove button enabled while add button disabled", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.componentRef.setInput("removeStorageDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); }); describe("button click events", () => { @@ -243,7 +265,7 @@ describe("StorageCardComponent", () => { }); it("should emit remove-storage action when remove button is clicked", () => { - setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + setupComponent(baseStorage); const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); diff --git a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts index 8c2070e59f9..2afbaf0d0b1 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts @@ -143,6 +143,33 @@ export const ActionsDisabled: Story = { used: 2.5, readableUsed: "2.5 GB", } satisfies Storage, - callsToActionDisabled: true, + addStorageDisabled: true, + removeStorageDisabled: true, + }, +}; + +export const AddStorageDisabled: Story = { + name: "Add Storage Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + addStorageDisabled: true, + removeStorageDisabled: false, + }, +}; + +export const RemoveStorageDisabled: Story = { + name: "Remove Storage Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + addStorageDisabled: false, + removeStorageDisabled: true, }, }; diff --git a/libs/subscription/src/components/storage-card/storage-card.component.ts b/libs/subscription/src/components/storage-card/storage-card.component.ts index 988f4a0ec60..483649434ff 100644 --- a/libs/subscription/src/components/storage-card/storage-card.component.ts +++ b/libs/subscription/src/components/storage-card/storage-card.component.ts @@ -12,7 +12,12 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { Storage } from "../../types/storage"; -export type StorageCardAction = "add-storage" | "remove-storage"; +export const StorageCardActions = { + AddStorage: "add-storage", + RemoveStorage: "remove-storage", +} as const; + +export type StorageCardAction = (typeof StorageCardActions)[keyof typeof StorageCardActions]; @Component({ selector: "billing-storage-card", @@ -25,7 +30,8 @@ export class StorageCardComponent { readonly storage = input.required(); - readonly callsToActionDisabled = input(false); + readonly addStorageDisabled = input(false); + readonly removeStorageDisabled = input(false); readonly callToActionClicked = output(); @@ -64,5 +70,5 @@ export class StorageCardComponent { return this.isFull() ? "danger" : "primary"; }); - readonly canRemoveStorage = computed(() => !this.isFull()); + protected readonly actions = StorageCardActions; } diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx index 0f605f0f05e..c9cc6df7263 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx @@ -67,14 +67,14 @@ import { SubscriptionCardComponent, BitwardenSubscription } from "@bitwarden/sub ### Outputs -| Output | Type | Description | -| --------------------- | ---------------- | ---------------------------------------------------------- | -| `callToActionClicked` | `PlanCardAction` | Emitted when a user clicks an action button in the callout | +| Output | Type | Description | +| --------------------- | ------------------------ | ---------------------------------------------------------- | +| `callToActionClicked` | `SubscriptionCardAction` | Emitted when a user clicks an action button in the callout | -**PlanCardAction Type:** +**SubscriptionCardAction Type:** ```typescript -type PlanCardAction = +type SubscriptionCardAction = | "contact-support" | "manage-invoices" | "reinstate-subscription" diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts index 3485f2a493a..cdb85360c74 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts @@ -14,7 +14,7 @@ describe("SubscriptionCardComponent", () => { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 50, }, }, diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts index abe5789382b..32976c89cc2 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts @@ -103,7 +103,7 @@ export const Active: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -131,7 +131,7 @@ export const ActiveWithUpgrade: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -157,7 +157,7 @@ export const Trial: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -185,7 +185,7 @@ export const TrialWithUpgrade: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -212,7 +212,7 @@ export const Incomplete: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -239,7 +239,7 @@ export const IncompleteExpired: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -266,7 +266,7 @@ export const PastDue: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -293,7 +293,7 @@ export const PendingCancellation: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -320,7 +320,7 @@ export const Unpaid: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -346,7 +346,7 @@ export const Canceled: Story = { passwordManager: { seats: { quantity: 1, - name: "members", + translationKey: "members", cost: 10.0, }, }, @@ -372,31 +372,30 @@ export const Enterprise: Story = { passwordManager: { seats: { quantity: 5, - name: "members", + translationKey: "members", cost: 7, }, additionalStorage: { quantity: 2, - name: "additionalStorageGB", + translationKey: "additionalStorageGB", cost: 0.5, }, }, secretsManager: { seats: { quantity: 3, - name: "members", + translationKey: "members", cost: 13, }, additionalServiceAccounts: { quantity: 5, - name: "additionalServiceAccountsV2", + translationKey: "additionalServiceAccountsV2", cost: 1, }, }, discount: { type: DiscountTypes.PercentOff, - active: true, - value: 0.25, + value: 25, }, cadence: "monthly", estimatedTax: 6.4, diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.ts index f52127a0104..ebfb41df6c2 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.ts @@ -16,12 +16,16 @@ import { CartSummaryComponent, Maybe } from "@bitwarden/pricing"; import { BitwardenSubscription, SubscriptionStatuses } from "@bitwarden/subscription"; import { I18nPipe } from "@bitwarden/ui-common"; -export type PlanCardAction = - | "contact-support" - | "manage-invoices" - | "reinstate-subscription" - | "update-payment" - | "upgrade-plan"; +export const SubscriptionCardActions = { + ContactSupport: "contact-support", + ManageInvoices: "manage-invoices", + ReinstateSubscription: "reinstate-subscription", + UpdatePayment: "update-payment", + UpgradePlan: "upgrade-plan", +} as const; + +export type SubscriptionCardAction = + (typeof SubscriptionCardActions)[keyof typeof SubscriptionCardActions]; type Badge = { text: string; variant: BadgeVariant }; @@ -33,7 +37,7 @@ type Callout = Maybe<{ callsToAction?: { text: string; buttonType: ButtonType; - action: PlanCardAction; + action: SubscriptionCardAction; }[]; }>; @@ -64,7 +68,7 @@ export class SubscriptionCardComponent { readonly showUpgradeButton = input(false); - readonly callToActionClicked = output(); + readonly callToActionClicked = output(); readonly badge = computed(() => { const subscription = this.subscription(); @@ -136,12 +140,12 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("updatePayment"), buttonType: "unstyled", - action: "update-payment", + action: SubscriptionCardActions.UpdatePayment, }, { text: this.i18nService.t("contactSupportShort"), buttonType: "unstyled", - action: "contact-support", + action: SubscriptionCardActions.ContactSupport, }, ], }; @@ -155,7 +159,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("contactSupportShort"), buttonType: "unstyled", - action: "contact-support", + action: SubscriptionCardActions.ContactSupport, }, ], }; @@ -172,7 +176,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("reinstateSubscription"), buttonType: "unstyled", - action: "reinstate-subscription", + action: SubscriptionCardActions.ReinstateSubscription, }, ], }; @@ -189,7 +193,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("upgradeNow"), buttonType: "unstyled", - action: "upgrade-plan", + action: SubscriptionCardActions.UpgradePlan, }, ], }; @@ -208,7 +212,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("manageInvoices"), buttonType: "unstyled", - action: "manage-invoices", + action: SubscriptionCardActions.ManageInvoices, }, ], }; @@ -225,7 +229,7 @@ export class SubscriptionCardComponent { { text: this.i18nService.t("manageInvoices"), buttonType: "unstyled", - action: "manage-invoices", + action: SubscriptionCardActions.ManageInvoices, }, ], }; diff --git a/libs/subscription/src/types/bitwarden-subscription.ts b/libs/subscription/src/types/bitwarden-subscription.ts index 15bf64d03aa..5c43ed20590 100644 --- a/libs/subscription/src/types/bitwarden-subscription.ts +++ b/libs/subscription/src/types/bitwarden-subscription.ts @@ -12,6 +12,8 @@ export const SubscriptionStatuses = { Unpaid: "unpaid", } as const; +export type SubscriptionStatus = (typeof SubscriptionStatuses)[keyof typeof SubscriptionStatuses]; + type HasCart = { cart: Cart; }; diff --git a/libs/subscription/src/types/storage.ts b/libs/subscription/src/types/storage.ts index beb187250dd..35df54cb4f2 100644 --- a/libs/subscription/src/types/storage.ts +++ b/libs/subscription/src/types/storage.ts @@ -1,3 +1,5 @@ +export const MAX_STORAGE_GB = 100; + export type Storage = { available: number; readableUsed: string; diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index 33dde9ae51a..7d28d857403 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -525,6 +525,20 @@ describe("VaultExportService", () => { const exportedData = actual as ExportedVaultAsString; expectEqualFolders(UserFolders, exportedData.data); }); + + it("does not export the key property in unencrypted exports", async () => { + // Create a cipher with a key property + const cipherWithKey = generateCipherView(false); + (cipherWithKey as any).key = "shouldBeDeleted"; + cipherService.getAllDecrypted.mockResolvedValue([cipherWithKey]); + + const actual = await exportService.getExport(userId, "json"); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + const parsed = JSON.parse(exportedData.data); + expect(parsed.items.length).toBe(1); + expect(parsed.items[0].key).toBeUndefined(); + }); }); export class FolderResponse { diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index ddda96b21e0..b30f14872ca 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -317,6 +317,7 @@ export class IndividualVaultExportService const cipher = new CipherWithIdExport(); cipher.build(c); cipher.collectionIds = null; + delete cipher.key; jsonDoc.items.push(cipher); }); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts index ed3a16516f2..c3df13c7945 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts @@ -3,13 +3,13 @@ import * as papa from "papaparse"; import { filter, firstValueFrom, map } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { - CollectionService, - CollectionData, - Collection, - CollectionDetailsResponse, CollectionView, -} from "@bitwarden/admin-console/common"; + CollectionDetailsResponse, + Collection, + CollectionData, +} from "@bitwarden/common/admin-console/models/collections"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; @@ -383,6 +383,7 @@ export class OrganizationVaultExportService decCiphers.forEach((c) => { const cipher = new CipherWithIdExport(); cipher.build(c); + delete cipher.key; jsonDoc.items.push(cipher); }); return JSON.stringify(jsonDoc, null, " "); diff --git a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts index 38257df603a..15b50a3809c 100644 --- a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts +++ b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts @@ -6,9 +6,9 @@ import { FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DIALOG_DATA, DialogRef, @@ -44,8 +44,10 @@ export const SendItemDialogResult = Object.freeze({ } as const); /** A result of the Send add/edit dialog. */ -export type SendItemDialogResult = (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult]; - +export type SendItemDialogResult = { + result: (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult]; + send?: SendView; +}; /** * Component for adding or editing a send item. */ @@ -93,7 +95,7 @@ export class SendAddEditDialogComponent { */ async onSendCreated(send: SendView) { // FIXME Add dialogService.open send-created dialog - this.dialogRef.close(SendItemDialogResult.Saved); + this.dialogRef.close({ result: SendItemDialogResult.Saved, send }); return; } @@ -101,14 +103,14 @@ export class SendAddEditDialogComponent { * Handles the event when the send is updated. */ async onSendUpdated(send: SendView) { - this.dialogRef.close(SendItemDialogResult.Saved); + this.dialogRef.close({ result: SendItemDialogResult.Saved }); } /** * Handles the event when the send is deleted. */ async onSendDeleted() { - this.dialogRef.close(SendItemDialogResult.Deleted); + this.dialogRef.close({ result: SendItemDialogResult.Deleted }); this.toastService.showToast({ variant: "success", diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts index 8f8390a170c..acdb7b56c2b 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts @@ -5,7 +5,7 @@ import { BehaviorSubject, of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { NewSendDropdownV2Component } from "./new-send-dropdown-v2.component"; diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts index 7e7c4a2005b..f586373de70 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts @@ -6,7 +6,7 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; diff --git a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts index e1474175267..b5cbeced209 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts @@ -7,7 +7,7 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; diff --git a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts index 0859986664a..4f30860b6a6 100644 --- a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts +++ b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts @@ -1,5 +1,5 @@ -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { Send } from "@bitwarden/common/tools/send/models/domain/send"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; /** diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts index 6724bb324c3..fa069b92ed2 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts @@ -6,9 +6,9 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DialogService, ToastService } from "@bitwarden/components"; import { CredentialGeneratorService } from "@bitwarden/generator-core"; diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index ec351bee923..e2b50eafc99 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -9,8 +9,8 @@ import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SectionComponent, SectionHeaderComponent, diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts index 4e4900039c7..7b00f17cc9c 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts @@ -4,9 +4,9 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, Validators, ReactiveFormsModule, FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendFileView } from "@bitwarden/common/tools/send/models/view/send-file.view"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, FormFieldModule, diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts index 0471ed90eef..53a9365bf99 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts @@ -18,8 +18,8 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { AsyncActionsModule, BitSubmitDirective, diff --git a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts index 343fa880795..9178991a028 100644 --- a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts +++ b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts @@ -7,8 +7,8 @@ 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 { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts index d885f279bc6..63f4b97105a 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts @@ -10,9 +10,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BadgeModule, ButtonModule, diff --git a/libs/tools/send/send-ui/src/send-list/send-list.component.ts b/libs/tools/send/send-ui/src/send-list/send-list.component.ts index d90f77913aa..17fc006feef 100644 --- a/libs/tools/send/send-ui/src/send-list/send-list.component.ts +++ b/libs/tools/send/send-ui/src/send-list/send-list.component.ts @@ -1,17 +1,8 @@ import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - computed, - effect, - inject, - input, - output, -} from "@angular/core"; +import { ChangeDetectionStrategy, Component, computed, effect, input, output } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NoResults, NoSendsIcon } from "@bitwarden/assets/svg"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { ButtonModule, @@ -57,8 +48,6 @@ export class SendListComponent { protected readonly noResultsIcon = NoResults; protected readonly sendListState = SendListState; - private i18nService = inject(I18nService); - readonly sends = input.required(); readonly loading = input(false); readonly disableSend = input(false); @@ -70,7 +59,7 @@ export class SendListComponent { ); protected readonly noSearchResults = computed( - () => this.showSearchBar() && (this.sends().length === 0 || this.searchText().length > 0), + () => this.showSearchBar() && this.sends().length === 0, ); // Reusable data source instance - updated reactively when sends change diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.html b/libs/tools/send/send-ui/src/send-table/send-table.component.html index 96b9519019e..cc2fca2c41c 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.html +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.html @@ -33,14 +33,16 @@ > {{ "disabled" | i18n }} } - @if (s.password) { + @if (s.authType !== authType.None) { + @let titleKey = + s.authType === authType.Email ? "emailProtected" : "passwordProtected"; - {{ "password" | i18n }} + {{ titleKey | i18n }} } @if (s.maxAccessCountReached) { = {}): SendView send.id = `send-${id}`; send.name = "My Send"; send.type = SendType.Text; + send.authType = AuthType.None; send.deletionDate = new Date("2030-01-01T12:00:00Z"); send.password = null as any; @@ -34,21 +36,29 @@ dataSource.data = [ createMockSend(2, { name: "Password Protected Send", type: SendType.Text, + authType: AuthType.Password, password: "123", }), createMockSend(3, { + name: "Email Protected Send", + type: SendType.Text, + authType: AuthType.Email, + emails: ["ckent@dailyplanet.com"], + }), + createMockSend(4, { name: "Disabled Send", type: SendType.Text, disabled: true, }), - createMockSend(4, { + createMockSend(5, { name: "Expired Send", type: SendType.File, expirationDate: new Date("2025-12-01T00:00:00Z"), }), - createMockSend(5, { + createMockSend(6, { name: "Max Access Reached", type: SendType.Text, + authType: AuthType.Password, maxAccessCount: 5, accessCount: 5, password: "123", @@ -69,7 +79,8 @@ export default { deletionDate: "Deletion Date", options: "Options", disabled: "Disabled", - password: "Password", + passwordProtected: "Password protected", + emailProtected: "Email protected", maxAccessCountReached: "Max access count reached", expired: "Expired", pendingDeletion: "Pending deletion", diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.ts index c912a01f98a..1475d9c65d1 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.ts +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.ts @@ -2,8 +2,9 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BadgeModule, ButtonModule, @@ -37,6 +38,7 @@ import { }) export class SendTableComponent { protected readonly sendType = SendType; + protected readonly authType = AuthType; /** * The data source containing the Send items to display in the table. diff --git a/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts b/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts index ef38938aba8..096ae95ad66 100644 --- a/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts +++ b/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts @@ -4,8 +4,8 @@ import { BehaviorSubject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendListFiltersService } from "./send-list-filters.service"; diff --git a/libs/tools/send/send-ui/src/services/send-list-filters.service.ts b/libs/tools/send/send-ui/src/services/send-list-filters.service.ts index b266ad08a69..cf84204ba0d 100644 --- a/libs/tools/send/send-ui/src/services/send-list-filters.service.ts +++ b/libs/tools/send/send-ui/src/services/send-list-filters.service.ts @@ -5,8 +5,8 @@ import { FormBuilder } from "@angular/forms"; import { map, Observable, startWith } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { ChipSelectOption } from "@bitwarden/components"; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts b/libs/vault/src/abstractions/vault-filter.service.ts similarity index 91% rename from apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts rename to libs/vault/src/abstractions/vault-filter.service.ts index 0e3ee69a2c6..22a1cf01cee 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/abstractions/vault-filter.service.ts +++ b/libs/vault/src/abstractions/vault-filter.service.ts @@ -2,7 +2,10 @@ // @ts-strict-ignore import { Observable } from "rxjs"; -import { CollectionAdminView, CollectionView } from "@bitwarden/admin-console/common"; +import { + CollectionAdminView, + CollectionView, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { UserId } from "@bitwarden/common/types/guid"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; @@ -13,7 +16,7 @@ import { CollectionFilter, FolderFilter, OrganizationFilter, -} from "../../shared/models/vault-filter.type"; +} from "../models/vault-filter.type"; export abstract class VaultFilterService { collapsedFilterNodes$: Observable>; diff --git a/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts b/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts index d1792da422c..35d3d8725ff 100644 --- a/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts +++ b/libs/vault/src/cipher-form/abstractions/cipher-form-config.service.ts @@ -1,6 +1,4 @@ -// 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 { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index e732513913d..475a026ff8b 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -12,14 +12,12 @@ import { import { BehaviorSubject, of } from "rxjs"; import { action } from "storybook/actions"; -// 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 { CollectionView } from "@bitwarden/admin-console/common"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import { NudgeStatus, NudgesService } from "@bitwarden/angular/vault"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts index ed70b4381d2..771500a9887 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts @@ -153,12 +153,12 @@ describe("UriOptionComponent", () => { component.writeValue({ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact }); fixture.detectChanges(); expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe( - "showMatchDetection https://example.com", + "showMatchDetectionNoPlaceholder", ); getToggleMatchDetectionBtn().click(); fixture.detectChanges(); expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe( - "hideMatchDetection https://example.com", + "hideMatchDetectionNoPlaceholder", ); }); }); diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts index 34ac284c3f3..fca22a5afb6 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts @@ -165,9 +165,11 @@ export class UriOptionComponent implements ControlValueAccessor { } protected get toggleTitle() { - return this.showMatchDetection - ? this.i18nService.t("hideMatchDetection", this.uriForm.value.uri) - : this.i18nService.t("showMatchDetection", this.uriForm.value.uri); + return this.i18nService.t( + this.showMatchDetection + ? "hideMatchDetectionNoPlaceholder" + : "showMatchDetectionNoPlaceholder", + ); } // NG_VALUE_ACCESSOR implementation diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index 62f23e77ec2..61ba2d19f8f 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -5,11 +5,13 @@ import { By } from "@angular/platform-browser"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; -// 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 { CollectionType, CollectionTypes, CollectionView } from "@bitwarden/admin-console/common"; import { ClientType } from "@bitwarden/client-type"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { + CollectionView, + CollectionType, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index 6dd2dafe5e8..4985f777a0e 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -6,13 +6,14 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; import { concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs"; -// 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 { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ClientType } from "@bitwarden/client-type"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { OrganizationUserType, PolicyType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html index 2ba9a16357a..0a46b83b086 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html +++ b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html @@ -9,7 +9,7 @@ {{ attachment.sizeName }} - + { it("should return true when filter matches collection id", () => { const filterFunction = createFilterFunction({ - collectionId: "collectionId", - organizationId: "organizationId", + collectionId: "collectionId" as CollectionId, + organizationId: "organizationId" as OrganizationId, }); const result = filterFunction(cipher); @@ -138,8 +137,8 @@ describe("createFilter", () => { it("should return false when filter does not match collection id", () => { const filterFunction = createFilterFunction({ - collectionId: "nonMatchingCollectionId", - organizationId: "organizationId", + collectionId: "nonMatchingCollectionId" as CollectionId, + organizationId: "organizationId" as OrganizationId, }); const result = filterFunction(cipher); @@ -149,7 +148,7 @@ describe("createFilter", () => { it("should return false when filter does not match organization id", () => { const filterFunction = createFilterFunction({ - organizationId: "nonMatchingOrganizationId", + organizationId: "nonMatchingOrganizationId" as OrganizationId, }); const result = filterFunction(cipher); @@ -186,7 +185,9 @@ describe("createFilter", () => { }); it("should return true when filter matches organization id", () => { - const filterFunction = createFilterFunction({ organizationId: "organizationId" }); + const filterFunction = createFilterFunction({ + organizationId: "organizationId" as OrganizationId, + }); const result = filterFunction(cipher); diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts b/libs/vault/src/models/filter-function.ts similarity index 97% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts rename to libs/vault/src/models/filter-function.ts index f010c529110..0252ef13094 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts +++ b/libs/vault/src/models/filter-function.ts @@ -1,4 +1,4 @@ -import { Unassigned } from "@bitwarden/admin-console/common"; +import { Unassigned } from "@bitwarden/common/admin-console/models/collections"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherViewLike, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts b/libs/vault/src/models/routed-vault-filter-bridge.model.ts similarity index 96% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts rename to libs/vault/src/models/routed-vault-filter-bridge.model.ts index f9a80791030..1d6d73ba7c5 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter-bridge.model.ts +++ b/libs/vault/src/models/routed-vault-filter-bridge.model.ts @@ -1,9 +1,9 @@ -import { Unassigned } from "@bitwarden/admin-console/common"; +import { Unassigned } from "@bitwarden/common/admin-console/models/collections"; import { CollectionId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; -import { RoutedVaultFilterBridgeService } from "../../services/routed-vault-filter-bridge.service"; +import { RoutedVaultFilterBridgeService } from "../services/routed-vault-filter-bridge.service"; import { All, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts b/libs/vault/src/models/routed-vault-filter.model.ts similarity index 91% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts rename to libs/vault/src/models/routed-vault-filter.model.ts index 13f1ed7b1a3..ddc1689b61f 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model.ts +++ b/libs/vault/src/models/routed-vault-filter.model.ts @@ -1,4 +1,4 @@ -import { Unassigned } from "@bitwarden/admin-console/common"; +import { Unassigned } from "@bitwarden/common/admin-console/models/collections"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; /** diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts b/libs/vault/src/models/vault-filter-section.type.ts similarity index 100% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts rename to libs/vault/src/models/vault-filter-section.type.ts diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.spec.ts b/libs/vault/src/models/vault-filter.model.spec.ts similarity index 93% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.spec.ts rename to libs/vault/src/models/vault-filter.model.spec.ts index 51fe837468c..6f90f5487bb 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.spec.ts +++ b/libs/vault/src/models/vault-filter.model.spec.ts @@ -1,8 +1,6 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { CollectionId } from "@bitwarden/common/types/guid"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -178,8 +176,8 @@ describe("VaultFilter", () => { it("should return true when filter matches collection id", () => { const filterFunction = createFilterFunction({ selectedCollectionNode: createCollectionFilterNode({ - id: "collectionId", - organizationId: "organizationId", + id: "collectionId" as CollectionId, + organizationId: "organizationId" as OrganizationId, }), }); @@ -191,8 +189,8 @@ describe("VaultFilter", () => { it("should return false when filter does not match collection id", () => { const filterFunction = createFilterFunction({ selectedCollectionNode: createCollectionFilterNode({ - id: "nonMatchingCollectionId", - organizationId: "organizationId", + id: "nonMatchingCollectionId" as CollectionId, + organizationId: "organizationId" as OrganizationId, }), }); @@ -204,7 +202,7 @@ describe("VaultFilter", () => { it("should return false when filter does not match organization id", () => { const filterFunction = createFilterFunction({ selectedOrganizationNode: createOrganizationFilterNode({ - id: "nonMatchingOrganizationId", + id: "nonMatchingOrganizationId" as OrganizationId, }), }); @@ -215,7 +213,9 @@ describe("VaultFilter", () => { it("should return false when filtering for my vault only", () => { const filterFunction = createFilterFunction({ - selectedOrganizationNode: createOrganizationFilterNode({ id: "MyVault" }), + selectedOrganizationNode: createOrganizationFilterNode({ + id: "MyVault" as OrganizationId, + }), }); const result = filterFunction(cipher); @@ -251,7 +251,9 @@ describe("VaultFilter", () => { it("should return true when filter matches organization id", () => { const filterFunction = createFilterFunction({ - selectedOrganizationNode: createOrganizationFilterNode({ id: "organizationId" }), + selectedOrganizationNode: createOrganizationFilterNode({ + id: "organizationId" as OrganizationId, + }), }); const result = filterFunction(cipher); @@ -276,7 +278,9 @@ describe("VaultFilter", () => { it("should return true when filtering for my vault only", () => { const cipher = createCipher({ organizationId: null }); const filterFunction = createFilterFunction({ - selectedOrganizationNode: createOrganizationFilterNode({ id: "MyVault" }), + selectedOrganizationNode: createOrganizationFilterNode({ + id: "MyVault" as OrganizationId, + }), }); const result = filterFunction(cipher); @@ -315,7 +319,7 @@ function createCollectionFilterNode( const collection = new CollectionView({ name: options.name ?? "Test Name", id: options.id ?? null, - organizationId: options.organizationId ?? "Org Id", + organizationId: options.organizationId ?? ("Org Id" as OrganizationId), }) as CollectionFilter; return new TreeNode(collection, {} as TreeNode); } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.ts b/libs/vault/src/models/vault-filter.model.ts similarity index 100% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.model.ts rename to libs/vault/src/models/vault-filter.model.ts diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts b/libs/vault/src/models/vault-filter.type.ts similarity index 90% rename from apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts rename to libs/vault/src/models/vault-filter.type.ts index 7a92db6a381..14e01ba0735 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter.type.ts +++ b/libs/vault/src/models/vault-filter.type.ts @@ -1,4 +1,4 @@ -import { CollectionAdminView } from "@bitwarden/admin-console/common"; +import { CollectionAdminView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherType } from "@bitwarden/common/vault/enums"; import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node"; diff --git a/libs/vault/src/services/archive-cipher-utilities.service.spec.ts b/libs/vault/src/services/archive-cipher-utilities.service.spec.ts index 76a4073325e..5df1bff9a56 100644 --- a/libs/vault/src/services/archive-cipher-utilities.service.spec.ts +++ b/libs/vault/src/services/archive-cipher-utilities.service.spec.ts @@ -5,6 +5,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -24,6 +25,7 @@ describe("ArchiveCipherUtilitiesService", () => { const mockCipher = new CipherView(); mockCipher.id = "cipher-id" as CipherId; const mockUserId = "user-id"; + const mockCipherData = { id: mockCipher.id } as CipherData; beforeEach(() => { cipherArchiveService = mock(); @@ -37,8 +39,8 @@ describe("ArchiveCipherUtilitiesService", () => { dialogService.openSimpleDialog.mockResolvedValue(true); passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true); - cipherArchiveService.archiveWithServer.mockResolvedValue(undefined); - cipherArchiveService.unarchiveWithServer.mockResolvedValue(undefined); + cipherArchiveService.archiveWithServer.mockResolvedValue(mockCipherData); + cipherArchiveService.unarchiveWithServer.mockResolvedValue(mockCipherData); i18nService.t.mockImplementation((key) => key); service = new ArchiveCipherUtilitiesService( diff --git a/libs/vault/src/services/archive-cipher-utilities.service.ts b/libs/vault/src/services/archive-cipher-utilities.service.ts index bbe7dba6715..93e752b57dd 100644 --- a/libs/vault/src/services/archive-cipher-utilities.service.ts +++ b/libs/vault/src/services/archive-cipher-utilities.service.ts @@ -25,16 +25,24 @@ export class ArchiveCipherUtilitiesService { private accountService: AccountService, ) {} - /** Archive a cipher, with confirmation dialog and password reprompt checks. */ - async archiveCipher(cipher: CipherView) { - const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); - if (!repromptPassed) { - return; + /** Archive a cipher, with confirmation dialog and password reprompt checks. + * + * @param cipher The cipher to archive + * @param skipReprompt Whether to skip the password reprompt check + * @returns The archived CipherData on success, or undefined on failure or cancellation + */ + async archiveCipher(cipher: CipherView, skipReprompt = false) { + if (!skipReprompt) { + const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); + if (!repromptPassed) { + return; + } } const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "archiveItem" }, - content: { key: "archiveItemConfirmDesc" }, + content: { key: "archiveItemDialogContent" }, + acceptButtonText: { key: "archiveVerb" }, type: "info", }); @@ -43,38 +51,47 @@ export class ArchiveCipherUtilitiesService { } const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherArchiveService - .archiveWithServer(cipher.id as CipherId, userId) - .then(() => { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("itemWasSentToArchive"), - }); - }) - .catch(() => { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("errorOccurred"), - }); + try { + const cipherResponse = await this.cipherArchiveService.archiveWithServer( + cipher.id as CipherId, + userId, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasSentToArchive"), }); + return cipherResponse; + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + return; + } } - /** Unarchives a cipher */ + /** Unarchives a cipher + * @param cipher The cipher to unarchive + * @returns The unarchived cipher on success, or undefined on failure + */ async unarchiveCipher(cipher: CipherView) { const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherArchiveService - .unarchiveWithServer(cipher.id as CipherId, userId) - .then(() => { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("itemWasUnarchived"), - }); - }) - .catch(() => { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("errorOccurred"), - }); + try { + const cipherResponse = await this.cipherArchiveService.unarchiveWithServer( + cipher.id as CipherId, + userId, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasUnarchived"), }); + return cipherResponse; + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + return; + } } } 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 c0afa950c41..51154c3cee9 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 @@ -2,11 +2,12 @@ import { mock, MockProxy } from "jest-mock-extended"; import { firstValueFrom, of, Subject } from "rxjs"; // eslint-disable-next-line no-restricted-imports -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { EventType } from "@bitwarden/common/enums"; @@ -41,6 +42,7 @@ describe("DefaultVaultItemsTransferService", () => { let mockToastService: MockProxy; let mockEventCollectionService: MockProxy; let mockConfigService: MockProxy; + let mockOrganizationUserApiService: MockProxy; const userId = "user-id" as UserId; const organizationId = "org-id" as OrganizationId; @@ -76,6 +78,7 @@ describe("DefaultVaultItemsTransferService", () => { mockToastService = mock(); mockEventCollectionService = mock(); mockConfigService = mock(); + mockOrganizationUserApiService = mock(); mockI18nService.t.mockImplementation((key) => key); transferInProgressValues = []; @@ -91,6 +94,7 @@ describe("DefaultVaultItemsTransferService", () => { mockToastService, mockEventCollectionService, mockConfigService, + mockOrganizationUserApiService, ); }); @@ -631,9 +635,15 @@ 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(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "leftOrganization", + }); expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); }); 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 c184b2c902e..6009fc97e7c 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.ts @@ -10,7 +10,7 @@ import { } from "rxjs"; // eslint-disable-next-line no-restricted-imports -import { CollectionService } from "@bitwarden/admin-console/common"; +import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -53,6 +53,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi private toastService: ToastService, private eventCollectionService: EventCollectionService, private configService: ConfigService, + private organizationUserApiService: OrganizationUserApiService, ) {} private _transferInProgressSubject = new BehaviorSubject(false); @@ -162,7 +163,12 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi ); if (!userAcceptedTransfer) { - // TODO: Revoke user from organization if they decline migration and show toast PM-29465 + await this.organizationUserApiService.revokeSelf(migrationInfo.enforcingOrganization.id); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("leftOrganization"), + }); await this.eventCollectionService.collect( EventType.Organization_ItemOrganization_Declined, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts b/libs/vault/src/services/routed-vault-filter-bridge.service.ts similarity index 93% rename from apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts rename to libs/vault/src/services/routed-vault-filter-bridge.service.ts index 936dfb0e675..1bff764964e 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter-bridge.service.ts +++ b/libs/vault/src/services/routed-vault-filter-bridge.service.ts @@ -4,22 +4,21 @@ import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; import { combineLatest, map, Observable } from "rxjs"; -import { Unassigned } from "@bitwarden/admin-console/common"; +import { Unassigned } from "@bitwarden/common/admin-console/models/collections"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; - -import { RoutedVaultFilterBridge } from "../shared/models/routed-vault-filter-bridge.model"; -import { RoutedVaultFilterModel, All } from "../shared/models/routed-vault-filter.model"; -import { VaultFilter } from "../shared/models/vault-filter.model"; import { + VaultFilterServiceAbstraction as VaultFilterService, + RoutedVaultFilterService, + RoutedVaultFilterBridge, + RoutedVaultFilterModel, + All, + VaultFilter, CipherTypeFilter, CollectionFilter, FolderFilter, OrganizationFilter, -} from "../shared/models/vault-filter.type"; - -import { VaultFilterService } from "./abstractions/vault-filter.service"; -import { RoutedVaultFilterService } from "./routed-vault-filter.service"; +} from "@bitwarden/vault"; /** * This file is part of a layer that is used to temporary bridge between URL filtering and the old state-in-code method. diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts b/libs/vault/src/services/routed-vault-filter.service.ts similarity index 86% rename from apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts rename to libs/vault/src/services/routed-vault-filter.service.ts index bc9da5e1692..9005d507da7 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service.ts +++ b/libs/vault/src/services/routed-vault-filter.service.ts @@ -1,13 +1,19 @@ -import { Injectable, OnDestroy } from "@angular/core"; +import { Injectable, OnDestroy, inject } from "@angular/core"; import { ActivatedRoute, NavigationExtras } from "@angular/router"; import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { SafeInjectionToken } from "@bitwarden/ui-common"; import { isRoutedVaultFilterItemType, RoutedVaultFilterModel, -} from "../shared/models/routed-vault-filter.model"; +} from "../models/routed-vault-filter.model"; + +/** + * Injection token for the base route path used in vault filter navigation. + */ +export const VAULT_FILTER_BASE_ROUTE = new SafeInjectionToken("VaultFilterBaseRoute"); /** * This service is an abstraction layer on top of ActivatedRoute that @@ -19,6 +25,7 @@ import { @Injectable() export class RoutedVaultFilterService implements OnDestroy { private onDestroy = new Subject(); + private baseRoute: string = inject(VAULT_FILTER_BASE_ROUTE, { optional: true }) ?? ""; /** * Filter values extracted from the URL. @@ -64,7 +71,7 @@ export class RoutedVaultFilterService implements OnDestroy { * @returns route that can be used with Router or RouterLink */ createRoute(filter: RoutedVaultFilterModel): [commands: any[], extras?: NavigationExtras] { - const commands: string[] = []; + const commands: string[] = this.baseRoute ? [this.baseRoute] : []; const extras: NavigationExtras = { queryParams: { collectionId: filter.collectionId ?? null, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts b/libs/vault/src/services/vault-filter.service.spec.ts similarity index 94% rename from apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts rename to libs/vault/src/services/vault-filter.service.spec.ts index c05459250c0..90af45e571f 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts +++ b/libs/vault/src/services/vault-filter.service.spec.ts @@ -7,16 +7,17 @@ import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider import { mock, MockProxy } from "jest-mock-extended"; import { firstValueFrom, of, ReplaySubject } from "rxjs"; -import { - CollectionService, - CollectionType, - CollectionTypes, - CollectionView, -} from "@bitwarden/admin-console/common"; +// eslint-disable-next-line no-restricted-imports +import { CollectionService } from "@bitwarden/admin-console/common"; import * as vaultFilterSvc from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionView, + CollectionType, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -31,7 +32,7 @@ import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/ import { VaultFilterService } from "./vault-filter.service"; jest.mock("@bitwarden/angular/vault/vault-filter/services/vault-filter.service", () => ({ - sortDefaultCollections: jest.fn(() => []), + sortDefaultCollections: jest.fn((): CollectionView[] => []), })); describe("vault filter service", () => { @@ -96,7 +97,6 @@ describe("vault filter service", () => { stateProvider, collectionService, accountService, - configService, ); collapsedGroupingsState = stateProvider.singleUser.getFake(mockUserId, COLLAPSED_GROUPINGS); organizations.next([]); @@ -127,7 +127,10 @@ describe("vault filter service", () => { describe("organizations", () => { beforeEach(() => { - const storedOrgs = [createOrganization("1", "org1"), createOrganization("2", "org2")]; + const storedOrgs = [ + createOrganization("1" as OrganizationId, "org1"), + createOrganization("2" as OrganizationId, "org2"), + ]; organizations.next(storedOrgs); organizationDataOwnershipPolicy.next(false); singleOrgPolicy.next(false); @@ -175,7 +178,9 @@ describe("vault filter service", () => { describe("filtered folders with organization", () => { beforeEach(() => { // Org must be updated before folderService else the subscription uses the null org default value - vaultFilterService.setOrganizationFilter(createOrganization("org test id", "Test Org")); + vaultFilterService.setOrganizationFilter( + createOrganization("org test id" as OrganizationId, "Test Org"), + ); }); it("returns folders filtered by current organization", async () => { const storedCiphers = [ @@ -225,7 +230,9 @@ describe("vault filter service", () => { describe("collections", () => { describe("filtered collections", () => { it("returns collections filtered by current organization", async () => { - vaultFilterService.setOrganizationFilter(createOrganization("org test id", "Test Org")); + vaultFilterService.setOrganizationFilter( + createOrganization("org test id" as OrganizationId, "Test Org"), + ); const storedCollections = [ createCollectionView("1", "collection 1", "org test id"), @@ -316,8 +323,8 @@ describe("vault filter service", () => { it("calls sortDefaultCollections with the correct args", async () => { const storedOrgs = [ - createOrganization("id-defaultOrg1", "org1"), - createOrganization("id-defaultOrg2", "org2"), + createOrganization("id-defaultOrg1" as OrganizationId, "org1"), + createOrganization("id-defaultOrg2" as OrganizationId, "org2"), ]; organizations.next(storedOrgs); @@ -353,7 +360,7 @@ describe("vault filter service", () => { }); }); - function createOrganization(id: string, name: string) { + function createOrganization(id: OrganizationId, name: string) { const org = new Organization(); org.id = id; org.name = name; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/libs/vault/src/services/vault-filter.service.ts similarity index 97% rename from apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts rename to libs/vault/src/services/vault-filter.service.ts index aad42506777..b21e140e023 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts +++ b/libs/vault/src/services/vault-filter.service.ts @@ -12,16 +12,18 @@ import { switchMap, } from "rxjs"; -import { - CollectionService, - CollectionTypes, - CollectionView, -} from "@bitwarden/admin-console/common"; +// eslint-disable-next-line no-restricted-imports +import { CollectionService } from "@bitwarden/admin-console/common"; import { sortDefaultCollections } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { cloneCollection } from "@bitwarden/common/admin-console/utils/collection-utils"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -36,16 +38,13 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/collapsed-groupings.state"; import { CipherListView } from "@bitwarden/sdk-internal"; -import { cloneCollection } from "@bitwarden/web-vault/app/admin-console/organizations/collections"; - import { + VaultFilterServiceAbstraction, CipherTypeFilter, CollectionFilter, FolderFilter, OrganizationFilter, -} from "../shared/models/vault-filter.type"; - -import { VaultFilterService as VaultFilterServiceAbstraction } from "./abstractions/vault-filter.service"; +} from "@bitwarden/vault"; const NestingDelimiter = "/"; diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs new file mode 100644 index 00000000000..5af87152fcc --- /dev/null +++ b/lint-staged.config.mjs @@ -0,0 +1,20 @@ +export default { + "*": "prettier --cache --ignore-unknown --write", + "*.ts": "eslint --cache --cache-strategy content --fix", + "apps/desktop/desktop_native/**/*.rs": (stagedFiles) => { + const relativeFiles = stagedFiles.map((f) => + f.replace(/^.*apps\/desktop\/desktop_native\//, ""), + ); + return [ + `sh -c 'cd apps/desktop/desktop_native && cargo +nightly fmt -- ${relativeFiles.join(" ")}'`, + `sh -c 'cd apps/desktop/desktop_native && cargo clippy --all-features --all-targets --tests -- -D warnings'`, + ]; + }, + "apps/desktop/desktop_native/**/Cargo.toml": () => { + return [ + `sh -c 'cd apps/desktop/desktop_native && cargo sort --workspace --check'`, + `sh -c 'cd apps/desktop/desktop_native && cargo +nightly udeps --workspace --all-features --all-targets'`, + `sh -c 'cd apps/desktop/desktop_native && cargo deny --log-level error --all-features check all'`, + ]; + }, +}; diff --git a/package-lock.json b/package-lock.json index eec3487b6d4..95842c6b409 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "big-integer": "1.6.52", "braintree-web-drop-in": "1.46.0", "buffer": "6.0.3", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "chalk": "4.1.2", "commander": "14.0.0", "core-js": "3.47.0", @@ -135,7 +135,7 @@ "electron-builder": "26.0.12", "electron-log": "5.4.3", "electron-reload": "2.0.0-alpha.1", - "electron-store": "8.2.0", + "electron-store": "11.0.2", "electron-updater": "6.6.4", "eslint": "9.26.0", "eslint-config-prettier": "10.1.2", @@ -491,7 +491,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2025.12.2" + "version": "2026.1.0" }, "libs/admin-console": { "name": "@bitwarden/admin-console", @@ -18367,13 +18367,14 @@ } }, "node_modules/atomically": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", - "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.0.tgz", + "integrity": "sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10.12.0" + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" } }, "node_modules/autoprefixer": { @@ -19321,9 +19322,9 @@ "license": "MIT" }, "node_modules/bufferutil": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", - "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -20638,46 +20639,40 @@ } }, "node_modules/conf": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz", - "integrity": "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==", + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/conf/-/conf-15.0.2.tgz", + "integrity": "sha512-JBSrutapCafTrddF9dH3lc7+T2tBycGF4uPkI4Js+g4vLLEhG6RZcFi3aJd5zntdf5tQxAejJt8dihkoQ/eSJw==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^8.6.3", - "ajv-formats": "^2.1.1", - "atomically": "^1.7.0", - "debounce-fn": "^4.0.0", - "dot-prop": "^6.0.1", - "env-paths": "^2.2.1", - "json-schema-typed": "^7.0.3", - "onetime": "^5.1.2", - "pkg-up": "^3.1.0", - "semver": "^7.3.5" + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^10.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.7.2", + "uint8array-extras": "^1.5.0" }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/conf/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/config-file-ts": { @@ -21500,16 +21495,16 @@ } }, "node_modules/debounce-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", - "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", + "integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^3.0.0" + "mimic-function": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -22304,16 +22299,32 @@ } }, "node_modules/dot-prop": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", - "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", + "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", "dev": true, "license": "MIT", "dependencies": { - "is-obj": "^2.0.0" + "type-fest": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/type-fest": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.2.0.tgz", + "integrity": "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -22598,14 +22609,33 @@ } }, "node_modules/electron-store": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-8.2.0.tgz", - "integrity": "sha512-ukLL5Bevdil6oieAOXz3CMy+OgaItMiVBg701MNlG6W5RaC0AHN7rvlqTCmeb6O7jP0Qa1KKYTE0xV0xbhF4Hw==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-11.0.2.tgz", + "integrity": "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ==", "dev": true, "license": "MIT", "dependencies": { - "conf": "^10.2.0", - "type-fest": "^2.17.0" + "conf": "^15.0.2", + "type-fest": "^5.0.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-store/node_modules/type-fest": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.2.0.tgz", + "integrity": "sha512-xxCJm+Bckc6kQBknN7i9fnP/xobQRsRQxR01CztFkp/h++yfVxUUcmMgfR2HttJx/dpWjS9ubVuyspJv24Q9DA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -27031,16 +27061,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -29454,9 +29474,9 @@ "license": "MIT" }, "node_modules/json-schema-typed": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", - "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "dev": true, "license": "BSD-2-Clause" }, @@ -32049,16 +32069,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", - "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -35776,85 +35786,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pkg-up": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-up/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-up/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/pkg-up/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/playwright": { "version": "1.53.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.1.tgz", @@ -40285,6 +40216,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "dev": true, + "license": "MIT" + }, "node_modules/style-loader": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", @@ -40598,6 +40546,19 @@ "npm": ">= 8" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "3.4.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", @@ -42398,6 +42359,19 @@ "node": ">=0.8.0" } }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -44305,6 +44279,13 @@ "node": ">=18" } }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 5a187885dea..55889b2cf67 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "electron-builder": "26.0.12", "electron-log": "5.4.3", "electron-reload": "2.0.0-alpha.1", - "electron-store": "8.2.0", + "electron-store": "11.0.2", "electron-updater": "6.6.4", "eslint": "9.26.0", "eslint-config-prettier": "10.1.2", @@ -177,7 +177,7 @@ "big-integer": "1.6.52", "braintree-web-drop-in": "1.46.0", "buffer": "6.0.3", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "chalk": "4.1.2", "commander": "14.0.0", "core-js": "3.47.0", @@ -224,10 +224,6 @@ "react-dom": "18.3.1", "@types/react": "18.3.27" }, - "lint-staged": { - "*": "prettier --cache --ignore-unknown --write", - "*.ts": "eslint --cache --cache-strategy content --fix" - }, "engines": { "node": ">=22.12.0", "npm": "~10"