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/build-web.yml b/.github/workflows/build-web.yml index 24a8df084a2..e626b629f5c 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -112,7 +112,7 @@ jobs: npm_command: dist:bit:selfhost - artifact_name: selfhosted-DEV license_type: "commercial" - image_name: web + image_name: web-dev npm_command: build:bit:selfhost:dev git_metadata: true - artifact_name: cloud-QA diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8327093441c..83c931b4fe0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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/.github/workflows/test.yml b/.github/workflows/test.yml index cf7251b259a..4280cabc812 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -111,7 +111,7 @@ jobs: working-directory: ./apps/desktop/desktop_native run: cargo build - - name: Test Ubuntu + - name: Linux unit tests if: ${{ matrix.os=='ubuntu-22.04' }} working-directory: ./apps/desktop/desktop_native run: | @@ -120,17 +120,21 @@ jobs: mkdir -p ~/.local/share/keyrings eval "$(printf '\n' | gnome-keyring-daemon --unlock)" eval "$(printf '\n' | /usr/bin/gnome-keyring-daemon --start)" - cargo test -- --test-threads=1 + cargo test --lib -- --test-threads=1 - - name: Test macOS + - name: MacOS unit tests if: ${{ matrix.os=='macos-14' }} working-directory: ./apps/desktop/desktop_native - run: cargo test -- --test-threads=1 + run: cargo test --lib -- --test-threads=1 - - name: Test Windows + - name: Windows unit tests if: ${{ matrix.os=='windows-2022'}} working-directory: ./apps/desktop/desktop_native - run: cargo test --workspace --exclude=desktop_napi -- --test-threads=1 + run: cargo test --lib --workspace --exclude=desktop_napi -- --test-threads=1 + + - name: Doc tests + working-directory: ./apps/desktop/desktop_native + run: cargo test --doc rust-coverage: name: Rust Coverage diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 46008206299..3b9ca37f6b4 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -585,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." }, @@ -1539,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": "نظافة كلمة المرور، صحة الحساب، وتقارير تسريبات البيانات للحفاظ على سلامة خزانتك." }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index fa28a709056..5eeedc430b1 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -585,6 +585,12 @@ "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." }, @@ -1539,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ı." }, diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 53ce24563e0..96042f12e19 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -585,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." }, @@ -1539,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": "Гігіена пароляў, здароўе ўліковага запісу і справаздачы аб уцечках даных для забеспячэння бяспекі вашага сховішча." }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 751c7fe12df..9a420b1d177 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -585,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" }, + "archived": { + "message": "Архивирано" + }, + "unarchiveAndSave": { + "message": "Разархивиране и запазване" + }, "upgradeToUseArchive": { "message": "За да се възползвате от архивирането, трябва да ползвате платен абонамент." }, @@ -1539,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Частно двустепенно удостоверяване чрез YubiKey и Duo." }, + "premiumSubscriptionEnded": { + "message": "Вашият абонамент за платения план е приключил" + }, + "archivePremiumRestart": { + "message": "Ако искате отново да получите достъп до архива си, трябва да подновите платения си абонамент. Ако редактирате данните за архивиран елемент преди подновяването, той ще бъде върнат в трезора." + }, + "restartPremium": { + "message": "Подновяване на платения абонамент" + }, "ppremiumSignUpReports": { "message": "Проверки в списъците с публикувани пароли, проверка на регистрациите и доклади за пробивите в сигурността, което спомага трезорът ви да е допълнително защитен." }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index d690cf29878..78b55611b50 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -585,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." }, @@ -1539,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": "আপনার ভল্টটি সুরক্ষিত রাখতে পাসওয়ার্ড স্বাস্থ্যকরন, অ্যাকাউন্ট স্বাস্থ্য এবং ডেটা লঙ্ঘনের প্রতিবেদন।" }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index 75ab751df5f..3bd578aa2a3 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 53cb057c188..97b9911536a 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 6905ba5f922..4c88374ed3e 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -585,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í." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 2e8f20f51ac..64a240add67 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 6d06071480b..640dbaf89b7 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index c2b5b1d1ef2..ce72944943c 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 2cf5650bafd..f20edf63793 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -585,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." }, @@ -1539,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 σας." }, diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index d3a393ecc37..dabd238e039 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -582,8 +582,14 @@ "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." @@ -1539,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." }, @@ -2458,6 +2473,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -4724,6 +4742,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.", @@ -5110,14 +5131,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?" @@ -5655,6 +5673,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 1940070310e..4fd70672f4a 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index fcc7725f3fc..a92aac915c1 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 3ee3d31d56b..f72635696a8 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index e39f87b6b86..a7730b536f7 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index 81905e2ee20..7a6ca24b3da 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 057db9f0290..c5af707763f 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -585,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." }, @@ -1539,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": "گزارش‌های بهداشت کلمه عبور، سلامت حساب کاربری و نقض داده‌ها برای ایمن نگهداشتن گاوصندوق شما." }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 62785164c08..cfab00e0849 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index b485c7f26f5..91fe7f70ced 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 238a5ca5e68..e884c3cd141 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 1544e6b822c..27c66bb1ce6 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 6f8b6f235a7..8c09d562f60 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -585,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "פריטים בארכיון מוחרגים מתוצאות חיפוש כללי והצעות למילוי אוטומטי. האם אתה בטוח שברצונך להעביר פריט זה לארכיון?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1539,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": "היגיינת סיסמאות, מצב בריאות החשבון, ודיווחים מעודכנים על פרצות חדשות בכדי לשמור על הכספת שלך בטוחה." }, diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 81202f05b58..50fc056527a 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -585,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." }, @@ -1539,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": "अपनी वॉल्ट को सुरक्षित रखने के लिए पासवर्ड स्वच्छता, खाता स्वास्थ्य और डेटा उल्लंघन रिपोर्ट।" }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 536529d1995..3e94ef40a30 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 049f2f776f1..3977f7a4fb5 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 1a628e4e765..08d346d57d8 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 3f662938cd7..32fe8e9ce54 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index b114ac090c0..0abea0e0236 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -585,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "アーカイブされたアイテムはここに表示され、通常の検索結果および自動入力の候補から除外されます。このアイテムをアーカイブしますか?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "アーカイブを使用するにはプレミアムメンバーシップが必要です。" }, @@ -1539,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": "保管庫を安全に保つための、パスワードやアカウントの健全性、データ侵害に関するレポート" }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index feb90164977..dc104c4a7f3 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index e0bce7c9224..db750969f43 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 46f73f568cb..7e0f427ea69 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -585,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." }, @@ -1539,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": "ನಿಮ್ಮ ವಾಲ್ಟ್ ಅನ್ನು ಸುರಕ್ಷಿತವಾಗಿರಿಸಲು ಪಾಸ್ವರ್ಡ್ ನೈರ್ಮಲ್ಯ, ಖಾತೆ ಆರೋಗ್ಯ ಮತ್ತು ಡೇಟಾ ಉಲ್ಲಂಘನೆ ವರದಿಗಳು." }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 10cf245290d..602c54c5f64 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -585,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "보관된 항목은 일반 검색 결과와 자동 완성 제안에서 제외됩니다. 이 항목을 보관하시겠습니까?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, @@ -1539,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": "보관함을 안전하게 유지하기 위한 암호 위생, 계정 상태, 데이터 유출 보고서" }, diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index f93790244cf..4ba52318c0b 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 596b4dfeaa0..e75255ab829 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index cc286de0c01..7afe2b75287 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -585,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." }, @@ -1539,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": "നിങ്ങളുടെ വാൾട് സൂക്ഷിക്കുന്നതിന്. പാസ്‌വേഡ് ശുചിത്വം, അക്കൗണ്ട് ആരോഗ്യം, ഡാറ്റ ലംഘന റിപ്പോർട്ടുകൾ." }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 546c03c8bfb..4d355428078 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index e0bce7c9224..db750969f43 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index d5015c3a87d..47f9ef4f7a9 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index e0bce7c9224..db750969f43 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index ecb9fc7a297..0157fa800f9 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index e0bce7c9224..db750969f43 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index e0bce7c9224..db750969f43 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 72912399978..705fa9565f1 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -574,7 +574,7 @@ "message": "Element został przeniesiony do archiwum" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "Element został usunięty z archiwum" }, "itemUnarchived": { "message": "Element został usunięty z archiwum" @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 5534c2b6ed7..5027fb35af2 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -574,7 +574,7 @@ "message": "O item foi enviado para o arquivo" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "O item foi desarquivado" }, "itemUnarchived": { "message": "O item foi desarquivado" @@ -585,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." }, @@ -1539,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." }, @@ -4833,19 +4848,19 @@ "message": "Saiba mais sobre os riscos" }, "autoConfirmSetup": { - "message": "Automatically confirm new users" + "message": "Confirmar automaticamente usuários novos" }, "autoConfirmSetupDesc": { - "message": "New users will be automatically confirmed while this device is unlocked." + "message": "Usuários novos serão confirmados automaticamente quando este dispositivo for desbloqueado." }, "autoConfirmSetupHint": { - "message": "What are the potential security risks?" + "message": "Quais são os possíveis problemas de segurança?" }, "autoConfirmEnabled": { - "message": "Turned on automatic confirmation" + "message": "Ativou a confirmação automática" }, "availableNow": { - "message": "Available now" + "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 8643ac017f6..7f97ab08623 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 5cbab8b51b0..139b94d1a55 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -585,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." }, @@ -1539,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ță." }, diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 0ca930ed9f3..0ab286c819d 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -585,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "Для использования архива требуется премиум-статус." }, @@ -1539,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": "Гигиена паролей, здоровье аккаунта и отчеты об утечках данных для обеспечения безопасности вашего хранилища." }, diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index bed0cd73187..82fed7f5fd4 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -585,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." }, @@ -1539,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 නය වාර්තා කරයි." }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 5f5a1113fef..f5619ef017d 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -585,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." }, @@ -1539,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čí." }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index bc5ed382df5..a0ccbdeadbc 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 48ef707f942..f5e85127639 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -585,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "Архивиране ставке су искључене из општих резултата претраге и предлога за ауто попуњавање. Јесте ли сигурни да желите да архивирате ову ставку?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "Премијум чланство је неопходно за употребу Архиве." }, @@ -1539,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": "Извештаји о хигијени лозинки, здравственом стању налога и кршењу података да бисте заштитили сеф." }, diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index e208186b408..2aef3f0d6d8 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index 3b1c93584b4..c329028526a 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -585,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "காப்பகப்படுத்தப்பட்ட உருப்படிகள் பொதுவான தேடல் முடிவுகள் மற்றும் தானியங்குநிரப்பு பரிந்துரைகளிலிருந்து விலக்கப்பட்டுள்ளன. இந்த உருப்படியை காப்பகப்படுத்த விரும்புகிறீர்களா?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "காப்பகத்தைப் பயன்படுத்த பிரீமியம் உறுப்பினர் தேவை." }, @@ -1539,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": "உங்கள் வால்ட்டைப் பாதுகாப்பாக வைத்திருக்க கடவுச்சொல் சுகாதாரம், கணக்கின் ஆரோக்கியம் மற்றும் டேட்டா மீறல் அறிக்கைகள்." }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index e0bce7c9224..db750969f43 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -585,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." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 320d94d746a..49ed1cebd89 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -585,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "รายการที่จัดเก็บถาวรจะไม่ถูกรวมในผลการค้นหาทั่วไปและคำแนะนำการป้อนอัตโนมัติ ยืนยันที่จะจัดเก็บรายการนี้ถาวรหรือไม่" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "ต้องเป็นสมาชิกพรีเมียมจึงจะใช้งานฟีเจอร์จัดเก็บถาวรได้" }, @@ -1539,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": "รายงานความปลอดภัยของรหัสผ่าน สุขภาพบัญชี และข้อมูลรั่วไหล เพื่อรักษาตู้นิรภัยให้ปลอดภัย" }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 35844d74041..344b8e03835 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -583,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." @@ -1539,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ı." }, @@ -4818,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" @@ -5255,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", @@ -5707,7 +5722,7 @@ "message": "Vulnerable password." }, "changeNow": { - "message": "Change now" + "message": "Şimdi değiştir" }, "missingWebsite": { "message": "Web sitesi eksik" @@ -5784,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 9b5b04dd7ec..01de2bbadf6 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -574,7 +574,7 @@ "message": "Запис архівовано" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "Запис розархівовано" }, "itemUnarchived": { "message": "Запис розархівовано" @@ -585,11 +585,17 @@ "archiveItemConfirmDesc": { "message": "Архівовані записи виключаються з результатів звичайного пошуку та пропозицій автозаповнення. Ви дійсно хочете архівувати цей запис?" }, + "archived": { + "message": "Архівовано" + }, + "unarchiveAndSave": { + "message": "Розархівувати й зберегти" + }, "upgradeToUseArchive": { "message": "Для використання архіву необхідна передплата Premium." }, "itemRestored": { - "message": "Item has been restored" + "message": "Запис відновлено" }, "edit": { "message": "Змінити" @@ -1329,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": { @@ -1539,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "Додаткові можливості двоетапної авторизації, як-от YubiKey та Duo." }, + "premiumSubscriptionEnded": { + "message": "Ваша передплата Premium завершилась" + }, + "archivePremiumRestart": { + "message": "Щоб відновити доступ до архіву, поновіть передплату Premium. Якщо ви редагуєте архівований запис перед поновленням, його буде повернуто назад у ваше сховище." + }, + "restartPremium": { + "message": "Поновити Premium" + }, "ppremiumSignUpReports": { "message": "Гігієна паролів, здоров'я облікового запису, а також звіти про вразливості даних, щоб зберігати ваше сховище в безпеці." }, @@ -4815,37 +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": "Automatically confirm new users" + "message": "Автоматично підтверджувати нових користувачів" }, "autoConfirmSetupDesc": { - "message": "New users will be automatically confirmed while this device is unlocked." + "message": "Нові користувачі будуть автоматично підтверджені, якщо пристрій розблоковано." }, "autoConfirmSetupHint": { - "message": "What are the potential security risks?" + "message": "Які потенційні ризики безпеки?" }, "autoConfirmEnabled": { - "message": "Turned on automatic confirmation" + "message": "Автоматичне підтвердження увімкнено" }, "availableNow": { - "message": "Available now" + "message": "Доступно зараз" }, "accountSecurity": { "message": "Безпека облікового запису" @@ -5704,10 +5719,10 @@ "message": "Цей запис ризикований, і не має адреси вебсайту. Додайте адресу вебсайту і змініть пароль для вдосконалення безпеки." }, "vulnerablePassword": { - "message": "Vulnerable password." + "message": "Вразливий пароль." }, "changeNow": { - "message": "Change now" + "message": "Змінити зараз" }, "missingWebsite": { "message": "Немає вебсайту" @@ -6086,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 83e8af63982..4ea7ebf885f 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -585,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ữ." }, @@ -1539,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." }, diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 756b3a995c3..9530388a4e5 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -585,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" }, + "archived": { + "message": "Archived" + }, + "unarchiveAndSave": { + "message": "Unarchive and save" + }, "upgradeToUseArchive": { "message": "需要高级会员才能使用归档。" }, @@ -1539,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": "密码健康、账户体检以及数据泄露报告,保障您的密码库安全。" }, @@ -2884,7 +2899,7 @@ "message": "排除域名更改已保存" }, "limitSendViews": { - "message": "查看次数限制" + "message": "限制查看次数" }, "limitSendViewsHint": { "message": "达到限额后,任何人无法查看此 Send。", @@ -2973,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": { @@ -4839,7 +4854,7 @@ "message": "当此设备已解锁时,新用户将被自动确认。" }, "autoConfirmSetupHint": { - "message": "有哪些潜在的安全风险?" + "message": "潜在的安全风险有哪些?" }, "autoConfirmEnabled": { "message": "启用了自动确认" diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index bcbc4db394e..76407d95621 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -585,6 +585,12 @@ "archiveItemConfirmDesc": { "message": "封存的項目將不會出現在一般搜尋結果或自動填入建議中。確定要封存此項目嗎?" }, + "archived": { + "message": "已封存" + }, + "unarchiveAndSave": { + "message": "取消封存並儲存" + }, "upgradeToUseArchive": { "message": "需要進階版會員才能使用封存功能。" }, @@ -1539,6 +1545,15 @@ "premiumSignUpTwoStepOptions": { "message": "專有的兩步驟登入選項,例如 YubiKey 和 Duo。" }, + "premiumSubscriptionEnded": { + "message": "您的進階版訂閱已到期" + }, + "archivePremiumRestart": { + "message": "若要重新存取您的封存項目,請重新啟用進階版訂閱。若您在重新啟用前編輯封存項目的詳細資料,它將會被移回您的密碼庫。" + }, + "restartPremium": { + "message": "重新啟用進階版" + }, "ppremiumSignUpReports": { "message": "密碼健康度檢查、提供帳戶體檢以及資料外洩報告,以保障您的密碼庫安全。" }, @@ -4815,16 +4830,16 @@ "message": "管理控制台" }, "admin": { - "message": "Admin" + "message": "管理員" }, "automaticUserConfirmation": { "message": "自動使用者確認" }, "automaticUserConfirmationHint": { - "message": "Automatically confirm pending users while this device is unlocked" + "message": "在此裝置解鎖時自動確認待處理的使用者" }, "autoConfirmOnboardingCallout": { - "message": "Save time with automatic user confirmation" + "message": "透過自動使用者確認節省時間" }, "autoConfirmWarning": { "message": "可能影響您的組織資料安全性。" @@ -4836,7 +4851,7 @@ "message": "自動確認新使用者" }, "autoConfirmSetupDesc": { - "message": "New users will be automatically confirmed while this device is unlocked." + "message": "當此裝置處於解鎖狀態時,新的使用者將會自動獲得確認。" }, "autoConfirmSetupHint": { "message": "潛在的安全性風險有哪些?" @@ -4845,7 +4860,7 @@ "message": "開啟自動確認" }, "availableNow": { - "message": "Available now" + "message": "立即可用" }, "accountSecurity": { "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/content/messaging/messenger.ts b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts index 61ed7a8ed08..257f7e9efd5 100644 --- a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts +++ b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts @@ -1,3 +1,5 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore import { Message, MessageTypes } from "./message"; const SENDER = "bitwarden-webauthn"; @@ -23,7 +25,7 @@ type Handler = ( * handling aborts and exceptions across separate execution contexts. */ export class Messenger { - private messageEventListener: ((event: MessageEvent) => void) | null = null; + private messageEventListener: (event: MessageEvent) => void | null = null; private onDestroy = new EventTarget(); /** @@ -58,12 +60,6 @@ export class Messenger { this.broadcastChannel.addEventListener(this.messageEventListener); } - private stripMetadata({ SENDER, senderId, ...message }: MessageWithMetadata): Message { - void SENDER; - void senderId; - return message; - } - /** * Sends a request to the content script and returns the response. * AbortController signals will be forwarded to the content script. @@ -78,9 +74,7 @@ export class Messenger { try { const promise = new Promise((resolve) => { - localPort.onmessage = (event: MessageEvent) => { - resolve(this.stripMetadata(event.data)); - }; + localPort.onmessage = (event: MessageEvent) => resolve(event.data); }); const abortListener = () => @@ -135,9 +129,7 @@ export class Messenger { try { const handlerResponse = await this.handler(message, abortController); - if (handlerResponse !== undefined) { - port.postMessage({ ...handlerResponse, SENDER }); - } + port.postMessage({ ...handlerResponse, SENDER }); } catch (error) { port.postMessage({ SENDER, 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/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 18eb8e2baf8..25fcb9038d8 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1083,6 +1083,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ setTimeout(this.autofillOverlayContentService.refreshMenuLayerPosition, 100); } }); + + this.autofillOverlayContentService.refreshMenuLayerPosition(); } }; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 51e91dcbfb6..3731e8a5423 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1516,6 +1516,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..c277b8d33f8 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,145 @@ 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"); + + // Trigger the load pipeline and allow async RxJS processing to complete + service["_loadBlobToMemory"](); + await flushPromises(); + + const set = service["_webAddressesSet"] as Set; + expect(set).toBeDefined(); + expect(set.has("phish.com")).toBe(true); + expect(set.has("badguy.net")).toBe(true); }); }); }); 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 4bc31f8ea60..7d5f04cc276 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,12 +3,15 @@ import { EMPTY, first, firstValueFrom, - map, + from, + of, share, + takeUntil, startWith, Subject, switchMap, tap, + map, } from "rxjs"; import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags"; @@ -20,11 +23,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. @@ -32,30 +38,50 @@ 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 - ), - ), - ), - ); + // While background scripts do not necessarily need destroying, + // processes in PhishingDataService are memory intensive. + // We are adding the destroy to guard against accidental leaks. + private _destroy$ = new Subject(); + + private _testWebAddresses = this.getTestWebAddresses().concat("phishing.testcategory.com"); // Included for QA to test in prod + private _phishingMetaState = this.globalStateProvider.get(PHISHING_DOMAINS_META_KEY); + private _phishingBlobState = this.globalStateProvider.get(PHISHING_DOMAINS_BLOB_KEY); + + // In-memory set loaded from blob for fast lookups without reading large storage repeatedly + private _webAddressesSet: Set | null = null; + // Loading variables for web addresses set + // Triggers a load for _webAddressesSet + private _loadTrigger$ = new Subject(); // How often are new web addresses added to the remote? readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours @@ -64,10 +90,15 @@ export class PhishingDataService { update$ = this._triggerUpdate$.pipe( startWith(undefined), // Always emit once 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 - tap((cachedState) => { - void this._backgroundUpdate(cachedState); + tap((metaState) => { + // Initial loading of web addresses set if not already loaded + if (!this._webAddressesSet) { + this._loadBlobToMemory(); + } + // Perform any updates in the background if needed + void this._backgroundUpdate(metaState); }), catchError((err: unknown) => { this.logService.error("[PhishingDataService] Background update failed to start.", err); @@ -75,6 +106,8 @@ export class PhishingDataService { }), ), ), + // Stop emitting when dispose() is called + takeUntil(this._destroy$), share(), ); @@ -86,6 +119,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(); }); @@ -93,6 +127,18 @@ export class PhishingDataService { ScheduledTaskNames.phishingDomainUpdate, this.UPDATE_INTERVAL_DURATION, ); + this._setupLoadPipeline(); + } + + dispose(): void { + // Signal all pipelines to stop and unsubscribe stored subscriptions + this._destroy$.next(); + this._destroy$.complete(); + + // Clear web addresses set from memory + if (this._webAddressesSet !== null) { + this._webAddressesSet = null; + } } /** @@ -102,12 +148,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; } @@ -115,54 +166,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) { @@ -173,8 +229,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) { @@ -202,10 +274,9 @@ export class PhishingDataService { } // Runs the update flow in the background and retries up to 3 times on failure - private async _backgroundUpdate(prev: PhishingData | null): Promise { - this.logService.info(`[PhishingDataService] Update triggered...`); - const phishingData = prev ?? { - webAddresses: [], + private async _backgroundUpdate(previous: PhishingDataMeta | null): Promise { + this.logService.info(`[PhishingDataService] Update web addresses triggered...`); + const phishingMeta: PhishingDataMeta = previous ?? { timestamp: 0, checksum: "", applicationVersion: "", @@ -217,15 +288,22 @@ export class PhishingDataService { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - const next = await this.getNextWebAddresses(phishingData); - if (next) { - await this._cachedState.update(() => next); - - // Performance logging - const elapsed = Date.now() - startTime; - this.logService.info(`[PhishingDataService] cache updated in ${elapsed}ms`); + const next = await this.getNextWebAddresses(phishingMeta); + if (!next) { + return; // No update needed } - return; + + if (next.meta) { + await this._phishingMetaState.update(() => next!.meta!); + } + if (next.blob) { + await this._phishingBlobState.update(() => next!.blob!); + 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}.`, @@ -243,4 +321,108 @@ export class PhishingDataService { } } } + + // Sets up the load pipeline to load the blob into memory when triggered + private _setupLoadPipeline(): void { + this._loadTrigger$ + .pipe( + switchMap(() => + this._phishingBlobState.state$.pipe( + first(), + switchMap((blobBase64) => { + if (!blobBase64) { + return of(undefined); + } + // Note: _decompressString wraps a promise that cannot be aborted + // If performance improvements are needed, consider migrating to a cancellable approach + return from(this._decompressString(blobBase64)).pipe( + map((text) => { + const lines = text.split(/\r?\n/); + const newWebAddressesSet = new Set(lines); + this._testWebAddresses.forEach((a) => newWebAddressesSet.add(a)); + this._webAddressesSet = new Set(newWebAddressesSet); + this.logService.info( + `[PhishingDataService] loaded ${this._webAddressesSet.size} addresses into memory from blob`, + ); + }), + ); + }), + catchError((err: unknown) => { + this.logService.error("[PhishingDataService] Failed to load blob into memory", err); + return of(undefined); + }), + ), + ), + catchError((err: unknown) => { + this.logService.error("[PhishingDataService] Load pipeline failed", err); + return of(undefined); + }), + takeUntil(this._destroy$), + share(), + ) + .subscribe(); + } + + // [FIXME] Move compression helpers to a shared utils library + // to separate from phishing data service. + // ------------------------- Blob and Compression Handling ------------------------- + private async _compressString(input: string): Promise { + try { + const stream = new Blob([input]).stream().pipeThrough(new CompressionStream("gzip")); + + const compressedBuffer = await new Response(stream).arrayBuffer(); + const bytes = new Uint8Array(compressedBuffer); + + // Modern browsers support direct toBase64 conversion + // For older support, use fallback + return (bytes as any).toBase64 + ? (bytes as any).toBase64() + : this._uint8ToBase64Fallback(bytes); + } catch (err) { + this.logService.error("[PhishingDataService] Compression failed", err); + return btoa(encodeURIComponent(input)); + } + } + + private async _decompressString(base64: string): Promise { + try { + // Modern browsers support direct toBase64 conversion + // For older support, use fallback + const bytes = (Uint8Array as any).fromBase64 + ? (Uint8Array as any).fromBase64(base64) + : this._base64ToUint8Fallback(base64); + if (bytes == null) { + throw new Error("Base64 decoding resulted in null"); + } + const byteResponse = new Response(bytes); + if (!byteResponse.body) { + throw new Error("Response body is null"); + } + const stream = byteResponse.body.pipeThrough(new DecompressionStream("gzip")); + const streamResponse = new Response(stream); + return await streamResponse.text(); + } catch (err) { + this.logService.error("[PhishingDataService] Decompression failed", err); + return decodeURIComponent(atob(base64)); + } + } + + // Trigger a load of the blob into memory + private _loadBlobToMemory(): void { + this._loadTrigger$.next(); + } + private _uint8ToBase64Fallback(bytes: Uint8Array): string { + const CHUNK_SIZE = 0x8000; // 32KB chunks + let binary = ""; + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + const chunk = bytes.subarray(i, i + CHUNK_SIZE); + binary += String.fromCharCode.apply(null, chunk as any); + } + return btoa(binary); + } + + private _base64ToUint8Fallback(base64: string): Uint8Array { + const binary = atob(base64); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); + } } diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index d90e872eef8..815007e1d4c 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -137,6 +137,9 @@ export class PhishingDetectionService { this._didInit = true; return () => { + // Dispose phishing data service resources + phishingDataService.dispose(); + initSub.unsubscribe(); this._didInit = false; diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts new file mode 100644 index 00000000000..75bd634b1fc --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts @@ -0,0 +1,375 @@ +import { ReadableStream as NodeReadableStream } from "stream/web"; + +import { mock, MockProxy } from "jest-mock-extended"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +import { PhishingIndexedDbService } from "./phishing-indexeddb.service"; + +describe("PhishingIndexedDbService", () => { + let service: PhishingIndexedDbService; + let logService: MockProxy; + + // Mock IndexedDB storage (keyed by URL for row-per-URL storage) + let mockStore: Map; + let mockObjectStore: any; + let mockTransaction: any; + let mockDb: any; + let mockOpenRequest: any; + + beforeEach(() => { + logService = mock(); + mockStore = new Map(); + + // Mock IDBObjectStore + mockObjectStore = { + put: jest.fn().mockImplementation((record: { url: string }) => { + const request = { + error: null as DOMException | null, + result: undefined as undefined, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + setTimeout(() => { + mockStore.set(record.url, record); + request.onsuccess?.(); + }, 0); + return request; + }), + get: jest.fn().mockImplementation((key: string) => { + const request = { + error: null as DOMException | null, + result: mockStore.get(key), + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + setTimeout(() => { + request.result = mockStore.get(key); + request.onsuccess?.(); + }, 0); + return request; + }), + clear: jest.fn().mockImplementation(() => { + const request = { + error: null as DOMException | null, + result: undefined as undefined, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + setTimeout(() => { + mockStore.clear(); + request.onsuccess?.(); + }, 0); + return request; + }), + openCursor: jest.fn().mockImplementation(() => { + const entries = Array.from(mockStore.entries()); + let index = 0; + const request = { + error: null as DOMException | null, + result: null as any, + onsuccess: null as ((e: any) => void) | null, + onerror: null as (() => void) | null, + }; + const advanceCursor = () => { + if (index < entries.length) { + const [, value] = entries[index]; + index++; + request.result = { + value, + continue: () => setTimeout(advanceCursor, 0), + }; + } else { + request.result = null; + } + request.onsuccess?.({ target: request }); + }; + setTimeout(advanceCursor, 0); + return request; + }), + }; + + // Mock IDBTransaction + mockTransaction = { + objectStore: jest.fn().mockReturnValue(mockObjectStore), + oncomplete: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + + // Trigger oncomplete after a tick + const originalObjectStore = mockTransaction.objectStore; + mockTransaction.objectStore = jest.fn().mockImplementation((...args: any[]) => { + setTimeout(() => mockTransaction.oncomplete?.(), 0); + return originalObjectStore(...args); + }); + + // Mock IDBDatabase + mockDb = { + transaction: jest.fn().mockReturnValue(mockTransaction), + close: jest.fn(), + objectStoreNames: { + contains: jest.fn().mockReturnValue(true), + }, + createObjectStore: jest.fn(), + }; + + // Mock IDBOpenDBRequest + mockOpenRequest = { + error: null as DOMException | null, + result: mockDb, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + onupgradeneeded: null as ((event: any) => void) | null, + }; + + // Mock indexedDB.open + const mockIndexedDB = { + open: jest.fn().mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onsuccess?.(); + }, 0); + return mockOpenRequest; + }), + }; + + global.indexedDB = mockIndexedDB as any; + + service = new PhishingIndexedDbService(logService); + }); + + afterEach(() => { + jest.clearAllMocks(); + delete (global as any).indexedDB; + }); + + describe("saveUrls", () => { + it("stores URLs in IndexedDB and returns true", async () => { + const urls = ["https://phishing.com", "https://malware.net"]; + + const result = await service.saveUrls(urls); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readwrite"); + expect(mockObjectStore.clear).toHaveBeenCalled(); + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + expect(mockDb.close).toHaveBeenCalled(); + }); + + it("handles empty array", async () => { + const result = await service.saveUrls([]); + + expect(result).toBe(true); + expect(mockObjectStore.clear).toHaveBeenCalled(); + }); + + it("trims whitespace from URLs", async () => { + const urls = [" https://example.com ", "\nhttps://test.org\n"]; + + await service.saveUrls(urls); + + expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://example.com" }); + expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://test.org" }); + }); + + it("skips empty lines", async () => { + const urls = ["https://example.com", "", " ", "https://test.org"]; + + await service.saveUrls(urls); + + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + }); + + it("handles duplicate URLs via upsert (keyPath deduplication)", async () => { + const urls = [ + "https://example.com", + "https://example.com", // duplicate + "https://test.org", + ]; + + const result = await service.saveUrls(urls); + + expect(result).toBe(true); + // put() is called 3 times, but mockStore (using Map with URL as key) + // only stores 2 unique entries - demonstrating upsert behavior + expect(mockObjectStore.put).toHaveBeenCalledTimes(3); + expect(mockStore.size).toBe(2); + }); + + it("logs error and returns false on failure", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const result = await service.saveUrls(["https://test.com"]); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Save failed", + expect.any(Error), + ); + }); + }); + + describe("hasUrl", () => { + it("returns true for existing URL", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + + const result = await service.hasUrl("https://example.com"); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readonly"); + expect(mockObjectStore.get).toHaveBeenCalledWith("https://example.com"); + }); + + it("returns false for non-existing URL", async () => { + const result = await service.hasUrl("https://notfound.com"); + + expect(result).toBe(false); + }); + + it("returns false on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const result = await service.hasUrl("https://example.com"); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Check failed", + expect.any(Error), + ); + }); + }); + + describe("loadAllUrls", () => { + it("loads all URLs using cursor", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + + const result = await service.loadAllUrls(); + + expect(result).toContain("https://example.com"); + expect(result).toContain("https://test.org"); + expect(result.length).toBe(2); + }); + + it("returns empty array when no data exists", async () => { + const result = await service.loadAllUrls(); + + expect(result).toEqual([]); + }); + + it("returns empty array on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const result = await service.loadAllUrls(); + + expect(result).toEqual([]); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Load failed", + expect.any(Error), + ); + }); + }); + + describe("saveUrlsFromStream", () => { + it("saves URLs from stream", async () => { + const content = "https://example.com\nhttps://test.org\nhttps://phishing.net"; + const stream = new NodeReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(content)); + controller.close(); + }, + }) as unknown as ReadableStream; + + const result = await service.saveUrlsFromStream(stream); + + expect(result).toBe(true); + expect(mockObjectStore.clear).toHaveBeenCalled(); + expect(mockObjectStore.put).toHaveBeenCalledTimes(3); + }); + + it("handles chunked stream data", async () => { + const content = "https://url1.com\nhttps://url2.com"; + const encoder = new TextEncoder(); + const encoded = encoder.encode(content); + + // Split into multiple small chunks + const stream = new NodeReadableStream({ + start(controller) { + controller.enqueue(encoded.slice(0, 5)); + controller.enqueue(encoded.slice(5, 10)); + controller.enqueue(encoded.slice(10)); + controller.close(); + }, + }) as unknown as ReadableStream; + + const result = await service.saveUrlsFromStream(stream); + + expect(result).toBe(true); + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + }); + + it("returns false on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const stream = new NodeReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("https://test.com")); + controller.close(); + }, + }) as unknown as ReadableStream; + + const result = await service.saveUrlsFromStream(stream); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Stream save failed", + expect.any(Error), + ); + }); + }); + + describe("database initialization", () => { + it("creates object store with keyPath on upgrade", async () => { + mockDb.objectStoreNames.contains.mockReturnValue(false); + + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onupgradeneeded?.({ target: mockOpenRequest }); + mockOpenRequest.onsuccess?.(); + }, 0); + return mockOpenRequest; + }); + + await service.hasUrl("https://test.com"); + + expect(mockDb.createObjectStore).toHaveBeenCalledWith("phishing-urls", { keyPath: "url" }); + }); + }); +}); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts new file mode 100644 index 00000000000..099839a38d9 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts @@ -0,0 +1,241 @@ +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +/** + * Record type for phishing URL storage in IndexedDB. + */ +type PhishingUrlRecord = { url: string }; + +/** + * IndexedDB storage service for phishing URLs. + * Stores URLs as individual rows. + */ +export class PhishingIndexedDbService { + private readonly DB_NAME = "bitwarden-phishing"; + private readonly STORE_NAME = "phishing-urls"; + private readonly DB_VERSION = 1; + private readonly CHUNK_SIZE = 50000; + + constructor(private logService: LogService) {} + + /** + * Opens the IndexedDB database, creating the object store if needed. + */ + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(this.DB_NAME, this.DB_VERSION); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result); + req.onupgradeneeded = (e) => { + const db = (e.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(this.STORE_NAME)) { + db.createObjectStore(this.STORE_NAME, { keyPath: "url" }); + } + }; + }); + } + + /** + * Clears all records from the phishing URLs store. + */ + private clearStore(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const req = db.transaction(this.STORE_NAME, "readwrite").objectStore(this.STORE_NAME).clear(); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(); + }); + } + + /** + * Saves an array of phishing URLs to IndexedDB. + * Atomically replaces all existing data. + * + * @param urls - Array of phishing URLs to save + * @returns `true` if save succeeded, `false` on error + */ + async saveUrls(urls: string[]): Promise { + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + await this.clearStore(db); + await this.saveChunked(db, urls); + return true; + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Save failed", error); + return false; + } finally { + db?.close(); + } + } + + /** + * Saves URLs in chunks to prevent transaction timeouts and UI freezes. + */ + private async saveChunked(db: IDBDatabase, urls: string[]): Promise { + const cleaned = urls.map((u) => u.trim()).filter(Boolean); + for (let i = 0; i < cleaned.length; i += this.CHUNK_SIZE) { + await this.saveChunk(db, cleaned.slice(i, i + this.CHUNK_SIZE)); + await new Promise((r) => setTimeout(r, 0)); // Yield to event loop + } + } + + /** + * Saves a single chunk of URLs in one transaction. + */ + private saveChunk(db: IDBDatabase, urls: string[]): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(this.STORE_NAME, "readwrite"); + const store = tx.objectStore(this.STORE_NAME); + for (const url of urls) { + store.put({ url } as PhishingUrlRecord); + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + /** + * Checks if a URL exists in the phishing database. + * + * @param url - The URL to check + * @returns `true` if URL exists, `false` if not found or on error + */ + async hasUrl(url: string): Promise { + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + return await this.checkUrlExists(db, url); + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Check failed", error); + return false; + } finally { + db?.close(); + } + } + + /** + * Performs the actual URL existence check using index lookup. + */ + private checkUrlExists(db: IDBDatabase, url: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(this.STORE_NAME, "readonly"); + const req = tx.objectStore(this.STORE_NAME).get(url); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result !== undefined); + }); + } + + /** + * Loads all phishing URLs from IndexedDB. + * + * @returns Array of all stored URLs, or empty array on error + */ + async loadAllUrls(): Promise { + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + return await this.getAllUrls(db); + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Load failed", error); + return []; + } finally { + db?.close(); + } + } + + /** + * Iterates all records using a cursor. + */ + private getAllUrls(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const urls: string[] = []; + const req = db + .transaction(this.STORE_NAME, "readonly") + .objectStore(this.STORE_NAME) + .openCursor(); + req.onerror = () => reject(req.error); + req.onsuccess = (e) => { + const cursor = (e.target as IDBRequest).result; + if (cursor) { + urls.push((cursor.value as PhishingUrlRecord).url); + cursor.continue(); + } else { + resolve(urls); + } + }; + }); + } + + /** + * Saves phishing URLs directly from a stream. + * Processes data incrementally to minimize memory usage. + * + * @param stream - ReadableStream of newline-delimited URLs + * @returns `true` if save succeeded, `false` on error + */ + async saveUrlsFromStream(stream: ReadableStream): Promise { + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + await this.clearStore(db); + await this.processStream(db, stream); + return true; + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Stream save failed", error); + return false; + } finally { + db?.close(); + } + } + + /** + * Processes a stream of URL data, parsing lines and saving in chunks. + */ + private async processStream(db: IDBDatabase, stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let urls: string[] = []; + + try { + while (true) { + const { done, value } = await reader.read(); + + // Decode BEFORE done check; stream: !done flushes on final call + buffer += decoder.decode(value, { stream: !done }); + + if (done) { + // Split remaining buffer by newlines in case it contains multiple URLs + const lines = buffer.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) { + urls.push(trimmed); + } + } + if (urls.length > 0) { + await this.saveChunk(db, urls); + } + break; + } + + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) { + urls.push(trimmed); + } + + if (urls.length >= this.CHUNK_SIZE) { + await this.saveChunk(db, urls); + urls = []; + await new Promise((r) => setTimeout(r, 0)); + } + } + } + } finally { + reader.releaseLock(); + } + } +} diff --git a/apps/browser/src/platform/browser/browser-popup-utils.spec.ts b/apps/browser/src/platform/browser/browser-popup-utils.spec.ts index 6e2175e3a79..cb04f30b589 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.spec.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.spec.ts @@ -1,7 +1,7 @@ import { createChromeTabMock } from "../../autofill/spec/autofill-mocks"; import { BrowserApi } from "./browser-api"; -import BrowserPopupUtils from "./browser-popup-utils"; +import BrowserPopupUtils, { PopupWidthOptions } from "./browser-popup-utils"; describe("BrowserPopupUtils", () => { afterEach(() => { @@ -152,7 +152,7 @@ describe("BrowserPopupUtils", () => { focused: false, alwaysOnTop: false, incognito: false, - width: 380, + width: PopupWidthOptions.default, }); jest.spyOn(BrowserApi, "createWindow").mockImplementation(); jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation(); @@ -168,7 +168,7 @@ describe("BrowserPopupUtils", () => { expect(BrowserApi.createWindow).toHaveBeenCalledWith({ type: "popup", focused: true, - width: 380, + width: PopupWidthOptions.default, height: 630, left: 85, top: 190, @@ -197,7 +197,7 @@ describe("BrowserPopupUtils", () => { expect(BrowserApi.createWindow).toHaveBeenCalledWith({ type: "popup", focused: true, - width: 380, + width: PopupWidthOptions.default, height: 630, left: 85, top: 190, @@ -214,7 +214,7 @@ describe("BrowserPopupUtils", () => { expect(BrowserApi.createWindow).toHaveBeenCalledWith({ type: "popup", focused: true, - width: 380, + width: PopupWidthOptions.default, height: 630, left: 85, top: 190, @@ -267,7 +267,7 @@ describe("BrowserPopupUtils", () => { expect(BrowserApi.createWindow).toHaveBeenCalledWith({ type: "popup", focused: true, - width: 380, + width: PopupWidthOptions.default, height: 630, left: 85, top: 190, @@ -290,7 +290,7 @@ describe("BrowserPopupUtils", () => { focused: false, alwaysOnTop: false, incognito: false, - width: 380, + width: PopupWidthOptions.default, state: "fullscreen", }); jest @@ -321,7 +321,7 @@ describe("BrowserPopupUtils", () => { focused: false, alwaysOnTop: false, incognito: false, - width: 380, + width: PopupWidthOptions.default, state: "fullscreen", }); diff --git a/apps/browser/src/platform/browser/browser-popup-utils.ts b/apps/browser/src/platform/browser/browser-popup-utils.ts index 8343799d0eb..c8dba57e708 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.ts @@ -10,9 +10,9 @@ import { BrowserApi } from "./browser-api"; * Value represents width in pixels */ export const PopupWidthOptions = Object.freeze({ - default: 380, - wide: 480, - "extra-wide": 600, + default: 480, + wide: 600, + narrow: 380, }); type PopupWidthOptions = typeof PopupWidthOptions; diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts index c6ffe1a6414..2e088b8161e 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -44,7 +44,7 @@ import { PopupTabNavigationComponent } from "./popup-tab-navigation.component"; @Component({ selector: "extension-container", template: ` -
+
`, @@ -678,7 +678,7 @@ export const WidthOptions: Story = { template: /* HTML */ `
Default:
-
+
Wide:
diff --git a/apps/browser/src/platform/popup/layout/popup-page.component.html b/apps/browser/src/platform/popup/layout/popup-page.component.html index 828d9947373..bb24fb800aa 100644 --- a/apps/browser/src/platform/popup/layout/popup-page.component.html +++ b/apps/browser/src/platform/popup/layout/popup-page.component.html @@ -25,7 +25,6 @@
(false); @@ -33,10 +39,21 @@ export class PopupPageComponent { protected readonly scrolled = signal(false); isScrolled = this.scrolled.asReadonly(); + constructor() { + this.scrollLayout.scrollableRef$ + .pipe( + filter((ref): ref is ElementRef => ref != null), + switchMap((ref) => + fromEvent(ref.nativeElement, "scroll").pipe( + startWith(null), + map(() => ref.nativeElement.scrollTop !== 0), + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((isScrolled) => this.scrolled.set(isScrolled)); + } + /** Accessible loading label for the spinner. Defaults to "loading" */ readonly loadingText = input(this.i18nService.t("loading")); - - handleScroll(event: Event) { - this.scrolled.set((event.currentTarget as HTMLElement).scrollTop !== 0); - } } diff --git a/apps/browser/src/platform/popup/layout/popup-size.service.ts b/apps/browser/src/platform/popup/layout/popup-size.service.ts index 0e4aacb9a97..ff3f09d0d01 100644 --- a/apps/browser/src/platform/popup/layout/popup-size.service.ts +++ b/apps/browser/src/platform/popup/layout/popup-size.service.ts @@ -83,7 +83,7 @@ export class PopupSizeService { } const pxWidth = PopupWidthOptions[width] ?? PopupWidthOptions.default; - document.body.style.minWidth = `${pxWidth}px`; + document.body.style.width = `${pxWidth}px`; } /** diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts index 8fdae06e28a..66c9f655b05 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -10,6 +10,7 @@ import { import { of } from "rxjs"; import { LockIcon, RegistrationCheckEmailIcon } from "@bitwarden/assets/svg"; +import { PopupWidthOptions } from "@bitwarden/browser/platform/browser/browser-popup-utils"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; @@ -243,7 +244,12 @@ export const DefaultContentExample: Story = { }), parameters: { chromatic: { - viewports: [380, 1280], + viewports: [ + PopupWidthOptions.default, + PopupWidthOptions.narrow, + PopupWidthOptions.wide, + 1280, + ], }, }, }; diff --git a/apps/browser/src/popup/scss/tailwind.css b/apps/browser/src/popup/scss/tailwind.css index f58950cc86a..0ef7b82bfed 100644 --- a/apps/browser/src/popup/scss/tailwind.css +++ b/apps/browser/src/popup/scss/tailwind.css @@ -60,7 +60,7 @@ } body { - width: 380px; + width: 480px; height: 100%; position: relative; min-height: inherit; @@ -84,9 +84,9 @@ animation: redraw 1s linear infinite; } - /** + /** * Text selection style: - * suppress user selection for most elements (to make it more app-like) + * suppress user selection for most elements (to make it more app-like) */ h1, h2, @@ -165,7 +165,7 @@ @apply tw-text-muted; } - /** + /** * Text selection style: * Set explicit selection styles (assumes primary accent color has sufficient * contrast against the background, so its inversion is also still readable) diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index c462e798a42..7b207f0fac1 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -27,8 +27,12 @@ import { WINDOW, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; -import { AUTOFILL_NUDGE_SERVICE } from "@bitwarden/angular/vault"; -import { SingleNudgeService } from "@bitwarden/angular/vault/services/default-single-nudge.service"; +import { + AUTOFILL_NUDGE_SERVICE, + AUTO_CONFIRM_NUDGE_SERVICE, + AutoConfirmNudgeService, +} from "@bitwarden/angular/vault"; +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { LoginComponentService, TwoFactorAuthComponentService, @@ -537,6 +541,7 @@ const safeProviders: SafeProvider[] = [ AccountService, BillingAccountProfileStateService, ConfigService, + LogService, OrganizationService, PlatformUtilsService, StateProvider, @@ -785,9 +790,14 @@ const safeProviders: SafeProvider[] = [ ], }), safeProvider({ - provide: AUTOFILL_NUDGE_SERVICE as SafeInjectionToken, + provide: AUTOFILL_NUDGE_SERVICE as SafeInjectionToken, useClass: BrowserAutofillNudgeService, - deps: [], + deps: [StateProvider, VaultProfileService, LogService], + }), + safeProvider({ + provide: AUTO_CONFIRM_NUDGE_SERVICE as SafeInjectionToken, + useClass: AutoConfirmNudgeService, + deps: [StateProvider, AutomaticUserConfirmationService], }), ]; 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 7230c565a48..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,7 +33,7 @@ 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 6728249b788..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 @@ -405,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 2df138f6e2e..d7de51ad20f 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 @@ -375,7 +375,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/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index e6dffdaff08..2c94d9c226b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -1,4 +1,3 @@ -import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling"; import { ChangeDetectionStrategy, Component, input, NO_ERRORS_SCHEMA } from "@angular/core"; import { TestBed, fakeAsync, flush, tick } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; @@ -394,21 +393,28 @@ describe("VaultV2Component", () => { expect(values[values.length - 1]).toBe(false); }); - it("ngAfterViewInit waits for allFilters$ then starts scroll position service", fakeAsync(() => { + it("passes popup-page scroll region element to scroll position service", fakeAsync(() => { + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + const readySubject$ = component["readySubject"] as unknown as BehaviorSubject; + const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject; const allFilters$ = filtersSvc.allFilters$ as unknown as Subject; - (component as any).virtualScrollElement = {} as CdkVirtualScrollableElement; - - component.ngAfterViewInit(); - expect(scrollSvc.start).not.toHaveBeenCalled(); - - allFilters$.next({ any: true }); + fixture.detectChanges(); tick(); - expect(scrollSvc.start).toHaveBeenCalledTimes(1); - expect(scrollSvc.start).toHaveBeenCalledWith((component as any).virtualScrollElement); + const scrollRegion = fixture.nativeElement.querySelector( + '[data-testid="popup-layout-scroll-region"]', + ) as HTMLElement; - flush(); + // Unblock loading + itemsLoading$.next(false); + readySubject$.next(true); + allFilters$.next({}); + tick(); + + expect(scrollSvc.start).toHaveBeenCalledWith(scrollRegion); })); it("showPremiumDialog opens PremiumUpgradeDialogComponent", () => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 761b366bcd2..4678e2733eb 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -1,7 +1,7 @@ import { LiveAnnouncer } from "@angular/cdk/a11y"; -import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrolling"; +import { ScrollingModule } from "@angular/cdk/scrolling"; import { CommonModule } from "@angular/common"; -import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { Component, DestroyRef, effect, inject, OnDestroy, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router, RouterModule } from "@angular/router"; import { @@ -47,6 +47,7 @@ import { ButtonModule, DialogService, NoItemsModule, + ScrollLayoutService, ToastService, TypographyModule, } from "@bitwarden/components"; @@ -119,11 +120,7 @@ type VaultState = UnionOfValues; ], providers: [{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }], }) -export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild(CdkVirtualScrollableElement) virtualScrollElement?: CdkVirtualScrollableElement; - +export class VaultV2Component implements OnInit, OnDestroy { NudgeType = NudgeType; cipherType = CipherType; private activeUserId$ = this.accountService.activeAccount$.pipe(getUserId); @@ -308,16 +305,21 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { }); } - ngAfterViewInit(): void { - if (this.virtualScrollElement) { - // The filters component can cause the size of the virtual scroll element to change, - // which can cause the scroll position to be land in the wrong spot. To fix this, - // wait until all filters are populated before restoring the scroll position. - this.allFilters$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(() => { - this.vaultScrollPositionService.start(this.virtualScrollElement!); + private readonly scrollLayout = inject(ScrollLayoutService); + + private readonly _scrollPositionEffect = effect((onCleanup) => { + const sub = combineLatest([this.scrollLayout.scrollableRef$, this.allFilters$, this.loading$]) + .pipe( + filter(([ref, _filters, loading]) => !!ref && !loading), + take(1), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(([ref]) => { + this.vaultScrollPositionService.start(ref!.nativeElement); }); - } - } + + onCleanup(() => sub.unsubscribe()); + }); async ngOnInit() { this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); 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 d2a4aaab3f0..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,6 +1,13 @@ - + + @if (cipher?.isArchived) { + + {{ "archived" | i18n }} + + } + + @if (cipher) { 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 9c536a7e85a..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 @@ -78,8 +78,9 @@ describe("ViewV2Component", () => { username: "test-username", password: "test-password", totp: "123", - uris: [], + uris: ["https://example.com"], }, + permissions: {}, card: {}, } as unknown as CipherView; @@ -155,7 +156,7 @@ describe("ViewV2Component", () => { { provide: CipherAuthorizationService, useValue: { - canDeleteCipher$: jest.fn().mockReturnValue(true), + canDeleteCipher$: jest.fn().mockReturnValue(of(true)), }, }, { @@ -644,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 64fa42bb2ba..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"; @@ -35,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, @@ -60,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"; @@ -97,6 +99,7 @@ type LoadAction = AsyncActionsModule, PopOutComponent, CalloutModule, + BadgeModule, ], providers: [ { provide: ViewPasswordHistoryService, useClass: BrowserViewPasswordHistoryService }, @@ -114,6 +117,7 @@ export class ViewV2Component { collections$: Observable; loadAction: LoadAction; senderTabId?: number; + routeAfterDeletion?: ROUTES_AFTER_EDIT_DELETION; protected showFooter$: Observable; protected userCanArchive$ = this.accountService.activeAccount$ @@ -149,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), @@ -228,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; } 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/services/vault-popup-scroll-position.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts index 562375f8f85..af21f664f2d 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.spec.ts @@ -1,4 +1,3 @@ -import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling"; import { fakeAsync, TestBed, tick } from "@angular/core/testing"; import { NavigationEnd, Router } from "@angular/router"; import { Subject, Subscription } from "rxjs"; @@ -66,21 +65,18 @@ describe("VaultPopupScrollPositionService", () => { }); describe("start", () => { - const elementScrolled$ = new Subject(); - const focus = jest.fn(); - const nativeElement = { - scrollTop: 0, - querySelector: jest.fn(() => ({ focus })), - addEventListener: jest.fn(), - style: { - visibility: "", - }, - }; - const virtualElement = { - elementScrolled: () => elementScrolled$, - getElementRef: () => ({ nativeElement }), - scrollTo: jest.fn(), - } as unknown as CdkVirtualScrollableElement; + let scrollElement: HTMLElement; + + beforeEach(() => { + scrollElement = document.createElement("div"); + + (scrollElement as any).scrollTo = jest.fn(function scrollTo(opts: { top?: number }) { + if (opts?.top != null) { + (scrollElement as any).scrollTop = opts.top; + } + }); + (scrollElement as any).scrollTop = 0; + }); afterEach(() => { // remove the actual subscription created by `.subscribe` @@ -89,47 +85,55 @@ describe("VaultPopupScrollPositionService", () => { describe("initial scroll position", () => { beforeEach(() => { - (virtualElement.scrollTo as jest.Mock).mockClear(); - nativeElement.querySelector.mockClear(); + ((scrollElement as any).scrollTo as jest.Mock).mockClear(); }); it("does not scroll when `scrollPosition` is null", () => { service["scrollPosition"] = null; - service.start(virtualElement); + service.start(scrollElement); - expect(virtualElement.scrollTo).not.toHaveBeenCalled(); + expect((scrollElement as any).scrollTo).not.toHaveBeenCalled(); }); - it("scrolls the virtual element to `scrollPosition`", fakeAsync(() => { + it("scrolls the element to `scrollPosition` (async via setTimeout)", fakeAsync(() => { service["scrollPosition"] = 500; - nativeElement.scrollTop = 500; - service.start(virtualElement); + service.start(scrollElement); tick(); - expect(virtualElement.scrollTo).toHaveBeenCalledWith({ behavior: "instant", top: 500 }); + expect((scrollElement as any).scrollTo).toHaveBeenCalledWith({ + behavior: "instant", + top: 500, + }); + expect((scrollElement as any).scrollTop).toBe(500); })); }); describe("scroll listener", () => { it("unsubscribes from any existing subscription", () => { - service.start(virtualElement); + service.start(scrollElement); expect(unsubscribe).toHaveBeenCalled(); }); - it("subscribes to `elementScrolled`", fakeAsync(() => { - virtualElement.measureScrollOffset = jest.fn(() => 455); + it("stores scrollTop on subsequent scroll events (skips first)", fakeAsync(() => { + service["scrollPosition"] = null; - service.start(virtualElement); + service.start(scrollElement); - elementScrolled$.next(null); // first subscription is skipped by `skip(1)` - elementScrolled$.next(null); + // First scroll event is intentionally ignored (equivalent to old skip(1)). + (scrollElement as any).scrollTop = 111; + scrollElement.dispatchEvent(new Event("scroll")); + tick(); + + expect(service["scrollPosition"]).toBeNull(); + + // Second scroll event should persist. + (scrollElement as any).scrollTop = 455; + scrollElement.dispatchEvent(new Event("scroll")); tick(); - expect(virtualElement.measureScrollOffset).toHaveBeenCalledTimes(1); - expect(virtualElement.measureScrollOffset).toHaveBeenCalledWith("top"); expect(service["scrollPosition"]).toBe(455); })); }); diff --git a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts index 5bfe0ec9331..7261fdd6633 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-scroll-position.service.ts @@ -1,8 +1,7 @@ -import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling"; import { inject, Injectable } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; -import { filter, skip, Subscription } from "rxjs"; +import { filter, fromEvent, Subscription } from "rxjs"; @Injectable({ providedIn: "root", @@ -31,24 +30,25 @@ export class VaultPopupScrollPositionService { } /** Scrolls the user to the stored scroll position and starts tracking scroll of the page. */ - start(virtualScrollElement: CdkVirtualScrollableElement) { + start(scrollElement: HTMLElement) { if (this.hasScrollPosition()) { // Use `setTimeout` to scroll after rendering is complete setTimeout(() => { - virtualScrollElement.scrollTo({ top: this.scrollPosition!, behavior: "instant" }); + scrollElement.scrollTo({ top: this.scrollPosition!, behavior: "instant" }); }); } this.scrollSubscription?.unsubscribe(); // Skip the first scroll event to avoid settings the scroll from the above `scrollTo` call - this.scrollSubscription = virtualScrollElement - ?.elementScrolled() - .pipe(skip(1)) - .subscribe(() => { - const offset = virtualScrollElement.measureScrollOffset("top"); - this.scrollPosition = offset; - }); + let skipped = false; + this.scrollSubscription = fromEvent(scrollElement, "scroll").subscribe(() => { + if (!skipped) { + skipped = true; + return; + } + this.scrollPosition = scrollElement.scrollTop; + }); } /** Stops the scroll listener from updating the stored location. */ diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts index e6515ae7461..e02ccf25f3e 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts @@ -79,7 +79,7 @@ export class AppearanceV2Component implements OnInit { protected readonly widthOptions: Option[] = [ { label: this.i18nService.t("default"), value: "default" }, { label: this.i18nService.t("wide"), value: "wide" }, - { label: this.i18nService.t("extraWide"), value: "extra-wide" }, + { label: this.i18nService.t("narrow"), value: "narrow" }, ]; constructor( 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/trash-list-items-container/trash-list-items-container.component.ts b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts index bad6011b2d8..edebdab062f 100644 --- a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts @@ -115,15 +115,22 @@ export class TrashListItemsContainerComponent { } async restore(cipher: PopupCipherViewLike) { + let toastMessage; try { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); await this.cipherService.restoreWithServer(cipher.id as string, activeUserId); + if (cipher.archivedDate) { + toastMessage = this.i18nService.t("archivedItemRestored"); + } else { + toastMessage = this.i18nService.t("restoredItem"); + } + await this.router.navigate(["/trash"]); this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t("restoredItem"), + message: toastMessage, }); } catch (e) { this.logService.error(e); 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/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/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..59dd36c6c91 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,17 @@ windows = { workspace = true, features = [ ] } windows-core = { workspace = true } +[dependencies] +anyhow = { workspace = true } + +[target.'cfg(windows)'.dev-dependencies] +windows = { workspace = true, features = [ + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_WindowsAndMessaging", + "Win32_Foundation", + "Win32_System_LibraryLoader", + "Win32_Graphics_Gdi", +] } + [lints] workspace = true diff --git a/apps/desktop/desktop_native/autotype/tests/integration_tests.rs b/apps/desktop/desktop_native/autotype/tests/integration_tests.rs new file mode 100644 index 00000000000..b87219f77fe --- /dev/null +++ b/apps/desktop/desktop_native/autotype/tests/integration_tests.rs @@ -0,0 +1,324 @@ +#![cfg(target_os = "windows")] + +use std::{ + sync::{Arc, Mutex}, + thread, + time::Duration, +}; + +use autotype::{get_foreground_window_title, type_input}; +use serial_test::serial; +use tracing::debug; +use windows::Win32::{ + Foundation::{COLORREF, HINSTANCE, HMODULE, HWND, LPARAM, LRESULT, WPARAM}, + Graphics::Gdi::{CreateSolidBrush, UpdateWindow, ValidateRect, COLOR_WINDOW}, + System::LibraryLoader::{GetModuleHandleA, GetModuleHandleW}, + UI::WindowsAndMessaging::*, +}; +use windows_core::{s, w, Result, PCSTR, PCWSTR}; + +struct TestWindow { + handle: HWND, + capture: Option, +} + +impl Drop for TestWindow { + fn drop(&mut self) { + // Clean up the InputCapture pointer + unsafe { + let capture_ptr = GetWindowLongPtrW(self.handle, GWLP_USERDATA) as *mut InputCapture; + if !capture_ptr.is_null() { + let _ = Box::from_raw(capture_ptr); + } + CloseWindow(self.handle).expect("window handle should be closeable"); + DestroyWindow(self.handle).expect("window handle should be destroyable"); + } + } +} + +// state to capture keyboard input +#[derive(Clone)] +struct InputCapture { + chars: Arc>>, +} + +impl InputCapture { + fn new() -> Self { + Self { + chars: Arc::new(Mutex::new(Vec::new())), + } + } + + fn get_chars(&self) -> Vec { + self.chars + .lock() + .expect("mutex should not be poisoned") + .clone() + } +} + +// Custom window procedure that captures input +unsafe extern "system" fn capture_input_proc( + handle: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_CREATE => { + // Store the InputCapture pointer in window data + let create_struct = lparam.0 as *const CREATESTRUCTW; + let capture_ptr = (*create_struct).lpCreateParams as *mut InputCapture; + SetWindowLongPtrW(handle, GWLP_USERDATA, capture_ptr as isize); + LRESULT(0) + } + WM_CHAR => { + // Get the InputCapture from window data + let capture_ptr = GetWindowLongPtrW(handle, GWLP_USERDATA) as *mut InputCapture; + if !capture_ptr.is_null() { + let capture = &*capture_ptr; + if let Some(ch) = char::from_u32(wparam.0 as u32) { + capture + .chars + .lock() + .expect("mutex should not be poisoned") + .push(ch); + } + } + LRESULT(0) + } + WM_DESTROY => { + PostQuitMessage(0); + LRESULT(0) + } + _ => DefWindowProcW(handle, msg, wparam, lparam), + } +} + +// A pointer to the window procedure +type ProcType = unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT; + +// +extern "system" fn show_window_proc( + handle: HWND, // the window handle + message: u32, // the system message + wparam: WPARAM, /* additional message information. The contents of the wParam parameter + * depend on the value of the message parameter. */ + lparam: LPARAM, /* additional message information. The contents of the lParam parameter + * depend on the value of the message parameter. */ +) -> LRESULT { + unsafe { + match message { + WM_PAINT => { + debug!("WM_PAINT"); + let res = ValidateRect(Some(handle), None); + debug_assert!(res.ok().is_ok()); + LRESULT(0) + } + WM_DESTROY => { + debug!("WM_DESTROY"); + PostQuitMessage(0); + LRESULT(0) + } + _ => DefWindowProcA(handle, message, wparam, lparam), + } + } +} + +impl TestWindow { + fn set_foreground(&self) -> Result<()> { + unsafe { + let _ = ShowWindow(self.handle, SW_SHOW); + let _ = SetForegroundWindow(self.handle); + let _ = UpdateWindow(self.handle); + let _ = SetForegroundWindow(self.handle); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + Ok(()) + } + + fn wait_for_input(&self, timeout_ms: u64) { + let start = std::time::Instant::now(); + while start.elapsed().as_millis() < timeout_ms as u128 { + process_messages(); + thread::sleep(Duration::from_millis(10)); + } + } +} + +fn process_messages() { + unsafe { + let mut msg = MSG::default(); + while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } +} + +fn create_input_window(title: PCWSTR, proc_type: ProcType) -> Result { + unsafe { + let instance = GetModuleHandleW(None).unwrap_or(HMODULE(std::ptr::null_mut())); + let instance: HINSTANCE = instance.into(); + debug_assert!(!instance.is_invalid()); + + let window_class = w!("show_window"); + + // Register window class with our custom proc + let wc = WNDCLASSW { + lpfnWndProc: Some(proc_type), + hInstance: instance, + lpszClassName: window_class, + hbrBackground: CreateSolidBrush(COLORREF( + (COLOR_WINDOW.0 + 1).try_into().expect("i32 to fit in u32"), + )), + ..Default::default() + }; + + let _atom = RegisterClassW(&wc); + + let capture = InputCapture::new(); + + // Pass InputCapture as lpParam + let capture_ptr = Box::into_raw(Box::new(capture.clone())); + + // Create window + // + let handle = CreateWindowExW( + WINDOW_EX_STYLE(0), + window_class, + title, + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + 400, + 300, + None, + None, + Some(instance), + Some(capture_ptr as *const _), + ) + .expect("window should be created"); + + // Process pending messages + process_messages(); + thread::sleep(Duration::from_millis(100)); + + Ok(TestWindow { + handle, + capture: Some(capture), + }) + } +} + +fn create_title_window(title: PCSTR, proc_type: ProcType) -> Result { + unsafe { + let instance = GetModuleHandleA(None)?; + let instance: HINSTANCE = instance.into(); + debug_assert!(!instance.is_invalid()); + + let window_class = s!("input_window"); + + // Register window class with our custom proc + // + let wc = WNDCLASSA { + hCursor: LoadCursorW(None, IDC_ARROW)?, + hInstance: instance, + lpszClassName: window_class, + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(proc_type), + ..Default::default() + }; + + let _atom = RegisterClassA(&wc); + + // Create window + // + let handle = CreateWindowExA( + WINDOW_EX_STYLE::default(), + window_class, + title, + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + 800, + 600, + None, + None, + Some(instance), + None, + ) + .expect("window should be created"); + + Ok(TestWindow { + handle, + capture: None, + }) + } +} + +#[serial] +#[test] +fn test_get_active_window_title_success() { + let title; + { + let window = create_title_window(s!("TITLE_FOOBAR"), show_window_proc).unwrap(); + window.set_foreground().unwrap(); + title = get_foreground_window_title().unwrap(); + } + + assert_eq!(title, "TITLE_FOOBAR\0".to_owned()); + + thread::sleep(Duration::from_millis(100)); +} + +#[serial] +#[test] +fn test_get_active_window_title_doesnt_fail_if_empty_title() { + let title; + { + let window = create_title_window(s!(""), show_window_proc).unwrap(); + window.set_foreground().unwrap(); + title = get_foreground_window_title(); + } + + assert_eq!(title.unwrap(), "".to_owned()); + + thread::sleep(Duration::from_millis(100)); +} + +#[serial] +#[test] +fn test_type_input_success() { + const TAB: u16 = 0x09; + let chars; + { + let window = create_input_window(w!("foo"), capture_input_proc).unwrap(); + window.set_foreground().unwrap(); + + type_input( + &[ + 0x66, 0x6F, 0x6C, 0x6C, 0x6F, 0x77, 0x5F, 0x74, 0x68, 0x65, TAB, 0x77, 0x68, 0x69, + 0x74, 0x65, 0x5F, 0x72, 0x61, 0x62, 0x62, 0x69, 0x74, + ], + &["Control".to_owned(), "Alt".to_owned(), "B".to_owned()], + ) + .unwrap(); + + // Wait for and process input messages + window.wait_for_input(250); + + // Verify captured input + let capture = window.capture.as_ref().unwrap(); + chars = capture.get_chars(); + } + + assert!(!chars.is_empty(), "No input captured"); + + let input_str = String::from_iter(chars.iter()); + let input_str = input_str.replace("\t", "_"); + + assert_eq!(input_str, "follow_the_white_rabbit"); + + thread::sleep(Duration::from_millis(100)); +} 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/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.json b/apps/desktop/electron-builder.json index 83bd2921551..481d12f02b4 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -85,7 +85,8 @@ "signIgnore": [ "MacOS/desktop_proxy", "MacOS/desktop_proxy.inherit", - "Contents/Plugins/autofill-extension.appex" + "Contents/Plugins/autofill-extension.appex", + "Frameworks/Electron Framework.framework/(Electron Framework|Libraries|Resources|Versions/Current)/.*" ], "target": ["dmg", "zip"] }, diff --git a/apps/desktop/macos/autofill-extension/autofill_extension_enabled.entitlements b/apps/desktop/macos/autofill-extension/autofill_extension_enabled.entitlements new file mode 100644 index 00000000000..49fda8f8af8 --- /dev/null +++ b/apps/desktop/macos/autofill-extension/autofill_extension_enabled.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + LTZ2PFU5D6.com.bitwarden.desktop + + com.apple.developer.authentication-services.autofill-credential-provider + + + diff --git a/apps/desktop/macos/desktop.xcodeproj/project.pbxproj b/apps/desktop/macos/desktop.xcodeproj/project.pbxproj index ed19fc9ef5d..c3cb34b6bea 100644 --- a/apps/desktop/macos/desktop.xcodeproj/project.pbxproj +++ b/apps/desktop/macos/desktop.xcodeproj/project.pbxproj @@ -256,7 +256,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = D83832AD2D67B9D0003FB9F8 /* ReleaseDeveloper.xcconfig */; buildSettings = { - CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension.entitlements"; + CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension_enabled.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; CODE_SIGN_STYLE = Manual; @@ -409,7 +409,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = D83832AB2D67B9AE003FB9F8 /* Debug.xcconfig */; buildSettings = { - CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension.entitlements"; + CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension_enabled.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; CODE_SIGN_STYLE = Manual; diff --git a/apps/desktop/macos/desktop.xcodeproj/xcshareddata/xcschemes/autofill-extension.xcscheme b/apps/desktop/macos/desktop.xcodeproj/xcshareddata/xcschemes/autofill-extension.xcscheme new file mode 100644 index 00000000000..18357be4570 --- /dev/null +++ b/apps/desktop/macos/desktop.xcodeproj/xcshareddata/xcschemes/autofill-extension.xcscheme @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 9e3b6ba23e0..f3650b3aaf6 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -15,8 +15,8 @@ "@bitwarden/storage-core": "file:../../../libs/storage-core", "module-alias": "2.2.3", "ts-node": "10.9.2", - "uuid": "13.0.0", - "yargs": "18.0.0" + "uuid": "9.0.1", + "yargs": "17.7.2" }, "devDependencies": { "@types/node": "22.19.3", @@ -121,7 +121,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -150,30 +149,6 @@ "node": ">=0.4.0" } }, - "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -181,19 +156,83 @@ "license": "MIT" }, "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "license": "ISC", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": ">=20" + "node": ">=12" } }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -209,12 +248,6 @@ "node": ">=0.3.1" } }, - "node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "license": "MIT" - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -233,16 +266,12 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", - "license": "MIT", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/make-error": { @@ -257,36 +286,49 @@ "integrity": "sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==", "license": "MIT" }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "license": "MIT", + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dependencies": { - "ansi-regex": "^6.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=12" + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "engines": { + "node": ">=8" } }, "node_modules/ts-node": { @@ -337,7 +379,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -353,16 +394,15 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], - "license": "MIT", "bin": { - "uuid": "dist-node/bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { @@ -371,23 +411,6 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "license": "MIT" }, - "node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -398,29 +421,28 @@ } }, "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "license": "MIT", + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dependencies": { - "cliui": "^9.0.1", + "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" + "yargs-parser": "^21.1.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=12" } }, "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "license": "ISC", + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=12" } }, "node_modules/yn": { diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 050bb653445..cdc9158bdca 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -20,8 +20,8 @@ "@bitwarden/logging": "dist/libs/logging/src", "module-alias": "2.2.3", "ts-node": "10.9.2", - "uuid": "13.0.0", - "yargs": "18.0.0" + "uuid": "9.0.1", + "yargs": "17.7.2" }, "devDependencies": { "@types/node": "22.19.3", @@ -31,6 +31,12 @@ "@bitwarden/common": "dist/libs/common/src", "@bitwarden/node/services/node-crypto-function.service": "dist/libs/node/src/services/node-crypto-function.service", "@bitwarden/storage-core": "dist/libs/storage-core/src", - "@bitwarden/logging": "dist/libs/logging/src" + "@bitwarden/logging": "dist/libs/logging/src", + "@bitwarden/client-type": "dist/libs/client-type/src", + "@bitwarden/state": "dist/libs/state/src", + "@bitwarden/state-internal": "dist/libs/state-internal/src", + "@bitwarden/messaging": "dist/libs/messaging/src", + "@bitwarden/guid": "dist/libs/guid/src", + "@bitwarden/serialization": "dist/libs/serialization/src" } } diff --git a/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-create.ts b/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-create.ts index 8d2d734677a..46021eb72ca 100644 --- a/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-create.ts +++ b/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-create.ts @@ -11,6 +11,7 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { CredentialCreatePayload } from "../../../src/models/native-messaging/encrypted-message-payloads/credential-create-payload"; import { LogUtils } from "../log-utils"; import NativeMessageService from "../native-message.service"; +import { TestRunnerSdkLoadService } from "../sdk-load.service"; import * as config from "../variables"; const argv: any = yargs(hideBin(process.argv)).option("name", { @@ -25,6 +26,10 @@ const { name } = argv; // 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 (async () => { + // Initialize SDK before using crypto functions + const sdkLoadService = new TestRunnerSdkLoadService(); + await sdkLoadService.loadAndInit(); + const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One); // Handshake LogUtils.logInfo("Sending Handshake"); @@ -42,7 +47,10 @@ const { name } = argv; // Get active account userId const status = await nativeMessageService.checkStatus(handshakeResponse.sharedKey); - const activeUser = status.payload.filter((a) => a.active === true && a.status === "unlocked")[0]; + const activeUser = status.payload.filter( + (a: { active: boolean; status: string; id: string }) => + a.active === true && a.status === "unlocked", + )[0]; if (activeUser === undefined) { LogUtils.logError("No active or unlocked user"); } diff --git a/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-retrieval.ts b/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-retrieval.ts index 2e55afbb36f..70b0bad9d66 100644 --- a/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-retrieval.ts +++ b/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-retrieval.ts @@ -7,6 +7,7 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { LogUtils } from "../log-utils"; import NativeMessageService from "../native-message.service"; +import { TestRunnerSdkLoadService } from "../sdk-load.service"; import * as config from "../variables"; const argv: any = yargs(hideBin(process.argv)).option("uri", { @@ -21,6 +22,10 @@ const { uri } = argv; // 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 (async () => { + // Initialize SDK before using crypto functions + const sdkLoadService = new TestRunnerSdkLoadService(); + await sdkLoadService.loadAndInit(); + const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One); // Handshake LogUtils.logInfo("Sending Handshake"); diff --git a/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-update.ts b/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-update.ts index 93598bf9eef..7ba5eef143a 100644 --- a/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-update.ts +++ b/apps/desktop/native-messaging-test-runner/src/commands/bw-credential-update.ts @@ -11,6 +11,7 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { CredentialUpdatePayload } from "../../../src/models/native-messaging/encrypted-message-payloads/credential-update-payload"; import { LogUtils } from "../log-utils"; import NativeMessageService from "../native-message.service"; +import { TestRunnerSdkLoadService } from "../sdk-load.service"; import * as config from "../variables"; // Command line arguments @@ -49,6 +50,10 @@ const { name, username, password, uri, credentialId } = argv; // 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 (async () => { + // Initialize SDK before using crypto functions + const sdkLoadService = new TestRunnerSdkLoadService(); + await sdkLoadService.loadAndInit(); + const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One); // Handshake LogUtils.logInfo("Sending Handshake"); @@ -67,7 +72,10 @@ const { name, username, password, uri, credentialId } = argv; // Get active account userId const status = await nativeMessageService.checkStatus(handshakeResponse.sharedKey); - const activeUser = status.payload.filter((a) => a.active === true && a.status === "unlocked")[0]; + const activeUser = status.payload.filter( + (a: { active: boolean; status: string; id: string }) => + a.active === true && a.status === "unlocked", + )[0]; if (activeUser === undefined) { LogUtils.logError("No active or unlocked user"); } diff --git a/apps/desktop/native-messaging-test-runner/src/commands/bw-generate-password.ts b/apps/desktop/native-messaging-test-runner/src/commands/bw-generate-password.ts index da914c67b4a..a0b449b02c7 100644 --- a/apps/desktop/native-messaging-test-runner/src/commands/bw-generate-password.ts +++ b/apps/desktop/native-messaging-test-runner/src/commands/bw-generate-password.ts @@ -7,6 +7,7 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { LogUtils } from "../log-utils"; import NativeMessageService from "../native-message.service"; +import { TestRunnerSdkLoadService } from "../sdk-load.service"; import * as config from "../variables"; const argv: any = yargs(hideBin(process.argv)).option("userId", { @@ -21,6 +22,10 @@ const { userId } = argv; // 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 (async () => { + // Initialize SDK before using crypto functions + const sdkLoadService = new TestRunnerSdkLoadService(); + await sdkLoadService.loadAndInit(); + const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One); // Handshake LogUtils.logInfo("Sending Handshake"); diff --git a/apps/desktop/native-messaging-test-runner/src/commands/bw-handshake.ts b/apps/desktop/native-messaging-test-runner/src/commands/bw-handshake.ts index 2ba5d469aaa..77a6ac652ad 100644 --- a/apps/desktop/native-messaging-test-runner/src/commands/bw-handshake.ts +++ b/apps/desktop/native-messaging-test-runner/src/commands/bw-handshake.ts @@ -4,11 +4,16 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { LogUtils } from "../log-utils"; import NativeMessageService from "../native-message.service"; +import { TestRunnerSdkLoadService } from "../sdk-load.service"; import * as config from "../variables"; // 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 (async () => { + // Initialize SDK before using crypto functions + const sdkLoadService = new TestRunnerSdkLoadService(); + await sdkLoadService.loadAndInit(); + const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One); const response = await nativeMessageService.sendHandshake( diff --git a/apps/desktop/native-messaging-test-runner/src/commands/bw-status.ts b/apps/desktop/native-messaging-test-runner/src/commands/bw-status.ts index 466e3fca52b..7014c4713c2 100644 --- a/apps/desktop/native-messaging-test-runner/src/commands/bw-status.ts +++ b/apps/desktop/native-messaging-test-runner/src/commands/bw-status.ts @@ -4,11 +4,16 @@ import { NativeMessagingVersion } from "@bitwarden/common/enums"; import { LogUtils } from "../log-utils"; import NativeMessageService from "../native-message.service"; +import { TestRunnerSdkLoadService } from "../sdk-load.service"; import * as config from "../variables"; // 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 (async () => { + // Initialize SDK before using crypto functions + const sdkLoadService = new TestRunnerSdkLoadService(); + await sdkLoadService.loadAndInit(); + const nativeMessageService = new NativeMessageService(NativeMessagingVersion.One); LogUtils.logInfo("Sending Handshake"); diff --git a/apps/desktop/native-messaging-test-runner/src/deferred.ts b/apps/desktop/native-messaging-test-runner/src/deferred.ts index da34d80ebb2..d3350ade5a4 100644 --- a/apps/desktop/native-messaging-test-runner/src/deferred.ts +++ b/apps/desktop/native-messaging-test-runner/src/deferred.ts @@ -4,8 +4,8 @@ // while allowing an unrelated event to fulfill it elsewhere. export default class Deferred { private promise: Promise; - private resolver: (T?) => void; - private rejecter: (Error?) => void; + private resolver!: (value?: T) => void; + private rejecter!: (reason?: Error) => void; constructor() { this.promise = new Promise((resolve, reject) => { diff --git a/apps/desktop/native-messaging-test-runner/src/ipc.service.ts b/apps/desktop/native-messaging-test-runner/src/ipc.service.ts index d8616e9757a..adb1e693d24 100644 --- a/apps/desktop/native-messaging-test-runner/src/ipc.service.ts +++ b/apps/desktop/native-messaging-test-runner/src/ipc.service.ts @@ -13,7 +13,7 @@ import { race } from "./race"; const DEFAULT_MESSAGE_TIMEOUT = 10 * 1000; // 10 seconds -export type MessageHandler = (MessageCommon) => void; +export type MessageHandler = (message: MessageCommon) => void; // FIXME: update to use a const object instead of a typescript enum // eslint-disable-next-line @bitwarden/platform/no-enums diff --git a/apps/desktop/native-messaging-test-runner/src/race.ts b/apps/desktop/native-messaging-test-runner/src/race.ts index 5ed778aa35b..a1c6cb04c5f 100644 --- a/apps/desktop/native-messaging-test-runner/src/race.ts +++ b/apps/desktop/native-messaging-test-runner/src/race.ts @@ -8,8 +8,8 @@ export const race = ({ promise: Promise; timeout: number; error?: Error; -}) => { - let timer = null; +}): Promise => { + let timer: NodeJS.Timeout | null = null; // Similar to Promise.all, but instead of waiting for all, it resolves once one promise finishes. // Using this so we can reject if the timeout threshold is hit @@ -20,7 +20,9 @@ export const race = ({ }), promise.then((value) => { - clearTimeout(timer); + if (timer != null) { + clearTimeout(timer); + } return value; }), ]); diff --git a/apps/desktop/native-messaging-test-runner/src/sdk-load.service.ts b/apps/desktop/native-messaging-test-runner/src/sdk-load.service.ts new file mode 100644 index 00000000000..d3f8289dffb --- /dev/null +++ b/apps/desktop/native-messaging-test-runner/src/sdk-load.service.ts @@ -0,0 +1,22 @@ +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; + +import { LogUtils } from "./log-utils"; + +/** + * SDK Load Service for the native messaging test runner. + * For Node.js environments, the SDK's Node.js build automatically loads WASM from the filesystem. + * No additional initialization is needed. + */ +export class TestRunnerSdkLoadService extends SdkLoadService { + async load(): Promise { + // In Node.js, @bitwarden/sdk-internal automatically loads the WASM file + // from node/bitwarden_wasm_internal_bg.wasm using fs.readFileSync. + // No explicit loading is required. + } + + override async loadAndInit(): Promise { + LogUtils.logInfo("Initializing SDK"); + await super.loadAndInit(); + LogUtils.logSuccess("SDK initialized"); + } +} diff --git a/apps/desktop/native-messaging-test-runner/tsconfig.json b/apps/desktop/native-messaging-test-runner/tsconfig.json index dcdf992f986..708559efc07 100644 --- a/apps/desktop/native-messaging-test-runner/tsconfig.json +++ b/apps/desktop/native-messaging-test-runner/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "../../../tsconfig.base.json", "compilerOptions": { "baseUrl": "./", "outDir": "dist", @@ -18,7 +19,13 @@ "@bitwarden/auth/*": ["../../../libs/auth/src/*"], "@bitwarden/common/*": ["../../../libs/common/src/*"], "@bitwarden/key-management": ["../../../libs/key-management/src/"], - "@bitwarden/node/*": ["../../../libs/node/src/*"] + "@bitwarden/node/*": ["../../../libs/node/src/*"], + "@bitwarden/state": ["../../../libs/state/src/index.ts"], + "@bitwarden/state-internal": ["../../../libs/state-internal/src/index.ts"], + "@bitwarden/client-type": ["../../../libs/client-type/src/index.ts"], + "@bitwarden/messaging": ["../../../libs/messaging/src/index.ts"], + "@bitwarden/guid": ["../../../libs/guid/src/index.ts"], + "@bitwarden/serialization": ["../../../libs/serialization/src/index.ts"] }, "plugins": [ { @@ -26,5 +33,6 @@ } ] }, - "exclude": ["node_modules"] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ad20e7c0e69..174f3a22a23 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -46,7 +46,7 @@ "pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never", "pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never", "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:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never -c.mac.identity=null -c.mas.identity=$CSC_NAME -c.mas.provisioningProfile=bitwarden_desktop_developer_id.provisionprofile -c.mas.entitlements=resources/entitlements.mas.autofill-enabled.plist", "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", "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never", diff --git a/apps/desktop/resources/entitlements.mas.autofill-enabled.plist b/apps/desktop/resources/entitlements.mas.autofill-enabled.plist new file mode 100644 index 00000000000..f25780e5c12 --- /dev/null +++ b/apps/desktop/resources/entitlements.mas.autofill-enabled.plist @@ -0,0 +1,42 @@ + + + + + com.apple.application-identifier + LTZ2PFU5D6.com.bitwarden.desktop + com.apple.developer.team-identifier + LTZ2PFU5D6 + com.apple.security.app-sandbox + + com.apple.security.application-groups + + LTZ2PFU5D6.com.bitwarden.desktop + + com.apple.security.cs.allow-jit + + com.apple.security.device.usb + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.temporary-exception.files.home-relative-path.read-write + + /Library/Application Support/Mozilla/NativeMessagingHosts/ + /Library/Application Support/Google/Chrome/NativeMessagingHosts/ + /Library/Application Support/Google/Chrome Beta/NativeMessagingHosts/ + /Library/Application Support/Google/Chrome Dev/NativeMessagingHosts/ + /Library/Application Support/Google/Chrome Canary/NativeMessagingHosts/ + /Library/Application Support/Chromium/NativeMessagingHosts/ + /Library/Application Support/Microsoft Edge/NativeMessagingHosts/ + /Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/ + /Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/ + /Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/ + /Library/Application Support/Vivaldi/NativeMessagingHosts/ + /Library/Application Support/Zen/NativeMessagingHosts/ + /Library/Application Support/net.imput.helium + + com.apple.developer.authentication-services.autofill-credential-provider + + + diff --git a/apps/desktop/resources/entitlements.mas.plist b/apps/desktop/resources/entitlements.mas.plist index 226e9827e37..9760af69e8b 100644 --- a/apps/desktop/resources/entitlements.mas.plist +++ b/apps/desktop/resources/entitlements.mas.plist @@ -34,7 +34,7 @@ /Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/ /Library/Application Support/Vivaldi/NativeMessagingHosts/ /Library/Application Support/Zen/NativeMessagingHosts/ - /Library/Application Support/net.imput.helium + /Library/Application Support/net.imput.helium/NativeMessagingHosts/ diff --git a/apps/desktop/scripts/after-sign.js b/apps/desktop/scripts/after-sign.js index 4275ec7d051..0e0e22fc24a 100644 --- a/apps/desktop/scripts/after-sign.js +++ b/apps/desktop/scripts/after-sign.js @@ -16,7 +16,9 @@ async function run(context) { const appPath = `${context.appOutDir}/${appName}.app`; const macBuild = context.electronPlatformName === "darwin"; const copySafariExtension = ["darwin", "mas"].includes(context.electronPlatformName); - const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName); // Disabled for mas builds + const isMasDevBuild = + context.electronPlatformName === "mas" && context.targets.at(0)?.name === "mas-dev"; + const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName) || isMasDevBuild; let shouldResign = false; @@ -31,7 +33,6 @@ async function run(context) { fse.mkdirSync(path.join(appPath, "Contents/PlugIns")); } fse.copySync(extensionPath, path.join(appPath, "Contents/PlugIns/autofill-extension.appex")); - shouldResign = true; } } 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..f9921ac11ef 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.html +++ b/apps/desktop/src/app/layout/desktop-layout.component.html @@ -1,8 +1,8 @@ - + - + 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..66613efd115 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, @@ -88,6 +89,7 @@ import { PlatformUtilsService, PlatformUtilsService as PlatformUtilsServiceAbstraction, } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; @@ -107,6 +109,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 +125,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"; @@ -422,6 +433,7 @@ const safeProviders: SafeProvider[] = [ InternalUserDecryptionOptionsServiceAbstraction, MessagingServiceAbstraction, AccountCryptographicStateService, + RegisterSdkService, ], }), safeProvider({ @@ -508,6 +520,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/services/set-initial-password/desktop-set-initial-password.service.spec.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts index 6b29a464e2c..9bb7d5077cf 100644 --- a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts +++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts @@ -1,8 +1,10 @@ -import { MockProxy, mock } from "jest-mock-extended"; +import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation"; import { + InitializeJitPasswordCredentials, SetInitialPasswordCredentials, SetInitialPasswordService, SetInitialPasswordUserType, @@ -19,12 +21,14 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management"; @@ -45,6 +49,7 @@ describe("DesktopSetInitialPasswordService", () => { let userDecryptionOptionsService: MockProxy; let messagingService: MockProxy; let accountCryptographicStateService: MockProxy; + let registerSdkService: MockProxy; beforeEach(() => { apiService = mock(); @@ -59,6 +64,7 @@ describe("DesktopSetInitialPasswordService", () => { userDecryptionOptionsService = mock(); messagingService = mock(); accountCryptographicStateService = mock(); + registerSdkService = mock(); sut = new DesktopSetInitialPasswordService( apiService, @@ -73,6 +79,7 @@ describe("DesktopSetInitialPasswordService", () => { userDecryptionOptionsService, messagingService, accountCryptographicStateService, + registerSdkService, ); }); @@ -179,4 +186,36 @@ describe("DesktopSetInitialPasswordService", () => { }); }); }); + + describe("initializePasswordJitPasswordUserV2Encryption(...)", () => { + it("should send a 'redrawMenu' message", async () => { + // Arrange + const credentials: InitializeJitPasswordCredentials = { + newPasswordHint: "newPasswordHint", + orgSsoIdentifier: "orgSsoIdentifier", + orgId: "orgId" as OrganizationId, + resetPasswordAutoEnroll: false, + newPassword: "newPassword123!", + salt: "user@example.com" as MasterPasswordSalt, + }; + const userId = "userId" as UserId; + + const superSpy = jest + .spyOn( + DefaultSetInitialPasswordService.prototype, + "initializePasswordJitPasswordUserV2Encryption", + ) + .mockResolvedValue(undefined); + + // Act + await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId); + + // Assert + expect(superSpy).toHaveBeenCalledWith(credentials, userId); + expect(messagingService.send).toHaveBeenCalledTimes(1); + expect(messagingService.send).toHaveBeenCalledWith("redrawMenu"); + + superSpy.mockRestore(); + }); + }); }); diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts index cedfa3fe589..f9fb8361056 100644 --- a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts +++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts @@ -1,6 +1,7 @@ import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation"; import { + InitializeJitPasswordCredentials, SetInitialPasswordCredentials, SetInitialPasswordService, SetInitialPasswordUserType, @@ -14,6 +15,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; import { UserId } from "@bitwarden/common/types/guid"; import { KdfConfigService, KeyService } from "@bitwarden/key-management"; @@ -34,6 +36,7 @@ export class DesktopSetInitialPasswordService protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, private messagingService: MessagingService, protected accountCryptographicStateService: AccountCryptographicStateService, + protected registerSdkService: RegisterSdkService, ) { super( apiService, @@ -47,6 +50,7 @@ export class DesktopSetInitialPasswordService organizationUserApiService, userDecryptionOptionsService, accountCryptographicStateService, + registerSdkService, ); } @@ -59,4 +63,13 @@ export class DesktopSetInitialPasswordService this.messagingService.send("redrawMenu"); } + + override async initializePasswordJitPasswordUserV2Encryption( + credentials: InitializeJitPasswordCredentials, + userId: UserId, + ): Promise { + await super.initializePasswordJitPasswordUserV2Encryption(credentials, userId); + + this.messagingService.send("redrawMenu"); + } } 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..dad0e541a4d 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,93 @@ -
-
+@if (useDrawerEditMode()) { +
+ @if (!disableSend()) { } -
- - - - -
+ + + +
- - - @if (action() == "add" || action() == "edit") { - - } - - - @if (!action()) { -
-
- - + } - + @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/policies/index.ts b/apps/web/src/app/admin-console/organizations/policies/index.ts index 3042be240f7..eb614e180e1 100644 --- a/apps/web/src/app/admin-console/organizations/policies/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/index.ts @@ -2,6 +2,6 @@ export { PoliciesComponent } from "./policies.component"; export { ossPolicyEditRegister } from "./policy-edit-register"; export { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component"; export { POLICY_EDIT_REGISTER } from "./policy-register-token"; -export { AutoConfirmPolicyDialogComponent } from "./auto-confirm-edit-policy-dialog.component"; export { AutoConfirmPolicy } from "./policy-edit-definitions"; export { PolicyEditDialogResult } from "./policy-edit-dialog.component"; +export * from "./policy-edit-dialogs"; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts index cf2a2929905..66074918084 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.ts @@ -15,8 +15,8 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SharedModule } from "../../../../shared"; -import { AutoConfirmPolicyDialogComponent } from "../auto-confirm-edit-policy-dialog.component"; import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component"; +import { AutoConfirmPolicyDialogComponent } from "../policy-edit-dialogs/auto-confirm-edit-policy-dialog.component"; export class AutoConfirmPolicy extends BasePolicyEditDefinition { name = "autoConfirm"; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts index 9b46e228af9..042f9771529 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts @@ -1,7 +1,10 @@ export { DisableSendPolicy } from "./disable-send.component"; export { DesktopAutotypeDefaultSettingPolicy } from "./autotype-policy.component"; export { MasterPasswordPolicy } from "./master-password.component"; -export { OrganizationDataOwnershipPolicy } from "./organization-data-ownership.component"; +export { + OrganizationDataOwnershipPolicy, + OrganizationDataOwnershipPolicyComponent, +} from "./organization-data-ownership.component"; export { PasswordGeneratorPolicy } from "./password-generator.component"; export { RemoveUnlockWithPinPolicy } from "./remove-unlock-with-pin.component"; export { RequireSsoPolicy } from "./require-sso.component"; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.html index 2b6c86b1fdc..bd2237bc2fd 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.html @@ -1,8 +1,57 @@ - - {{ "personalOwnershipExemption" | i18n }} - +

+ {{ "organizationDataOwnershipDescContent" | i18n }} + + {{ "organizationDataOwnershipContentAnchor" | i18n }}. + +

{{ "turnOn" | i18n }} + + + + {{ "organizationDataOwnershipWarningTitle" | i18n }} + +
+ {{ "organizationDataOwnershipWarningContentTop" | i18n }} +
+
    +
  • + {{ "organizationDataOwnershipWarning1" | i18n }} +
  • +
  • + {{ "organizationDataOwnershipWarning2" | i18n }} +
  • +
  • + {{ "organizationDataOwnershipWarning3" | i18n }} +
  • +
+
+ {{ "organizationDataOwnershipWarningContentBottom" | i18n }} + + {{ "organizationDataOwnershipContentAnchor" | i18n }}. + +
+
+ + + + + + +
+
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts index ceecf8f2ecc..e4a07b7440d 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/organization-data-ownership.component.ts @@ -1,22 +1,38 @@ -import { ChangeDetectionStrategy, Component } from "@angular/core"; -import { of, Observable } from "rxjs"; +import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core"; +import { lastValueFrom, map, Observable } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { CenterPositionStrategy, DialogService } from "@bitwarden/components"; +import { EncString } from "@bitwarden/sdk-internal"; import { SharedModule } from "../../../../shared"; import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component"; +export interface VNextPolicyRequest { + policy: PolicyRequest; + metadata: { + defaultUserCollectionName: string; + }; +} + export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition { name = "organizationDataOwnership"; - description = "personalOwnershipPolicyDesc"; + description = "organizationDataOwnershipDesc"; type = PolicyType.OrganizationDataOwnership; component = OrganizationDataOwnershipPolicyComponent; + showDescription = false; - display$(organization: Organization, configService: ConfigService): Observable { - // TODO Remove this entire component upon verifying that it can be deleted due to its sole reliance of the CreateDefaultLocation feature flag - return of(false); + override display$(organization: Organization, configService: ConfigService): Observable { + return configService + .getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems) + .pipe(map((enabled) => !enabled)); } } @@ -26,4 +42,61 @@ export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition { imports: [SharedModule], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class OrganizationDataOwnershipPolicyComponent extends BasePolicyEditComponent {} +export class OrganizationDataOwnershipPolicyComponent + extends BasePolicyEditComponent + implements OnInit +{ + constructor( + private dialogService: DialogService, + private i18nService: I18nService, + private encryptService: EncryptService, + ) { + super(); + } + + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild("dialog", { static: true }) warningContent!: TemplateRef; + + override async confirm(): Promise { + if (this.policyResponse?.enabled && !this.enabled.value) { + const dialogRef = this.dialogService.open(this.warningContent, { + positionStrategy: new CenterPositionStrategy(), + }); + const result = await lastValueFrom(dialogRef.closed); + return Boolean(result); + } + return true; + } + + async buildVNextRequest(orgKey: OrgKey): Promise { + if (!this.policy) { + throw new Error("Policy was not found"); + } + + const defaultUserCollectionName = await this.getEncryptedDefaultUserCollectionName(orgKey); + + const request: VNextPolicyRequest = { + policy: { + enabled: this.enabled.value ?? false, + data: this.buildRequestData(), + }, + metadata: { + defaultUserCollectionName, + }, + }; + + return request; + } + + private async getEncryptedDefaultUserCollectionName(orgKey: OrgKey): Promise { + const defaultCollectionName = this.i18nService.t("myItems"); + const encrypted = await this.encryptService.encryptString(defaultCollectionName, orgKey); + + if (!encrypted.encryptedString) { + throw new Error("Encryption error"); + } + + return encrypted.encryptedString; + } +} diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html index bd2237bc2fd..e6c93b323c2 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html @@ -1,57 +1,52 @@ -

- {{ "organizationDataOwnershipDescContent" | i18n }} - - {{ "organizationDataOwnershipContentAnchor" | i18n }}. - -

+ - - - {{ "turnOn" | i18n }} - + +

+ {{ "centralizeDataOwnershipDesc" | i18n }} + + {{ "centralizeDataOwnershipContentAnchor" | i18n }} + + +

- - - {{ "organizationDataOwnershipWarningTitle" | i18n }} - -
- {{ "organizationDataOwnershipWarningContentTop" | i18n }} -
-
    -
  • - {{ "organizationDataOwnershipWarning1" | i18n }} -
  • -
  • - {{ "organizationDataOwnershipWarning2" | i18n }} -
  • -
  • - {{ "organizationDataOwnershipWarning3" | i18n }} -
  • -
-
- {{ "organizationDataOwnershipWarningContentBottom" | i18n }} - - {{ "organizationDataOwnershipContentAnchor" | i18n }}. - -
-
- - - - - - -
+
+ {{ "benefits" | i18n }}: +
    +
  • + {{ "centralizeDataOwnershipBenefit1" | i18n }} +
  • +
  • + {{ "centralizeDataOwnershipBenefit2" | i18n }} +
  • +
  • + {{ "centralizeDataOwnershipBenefit3" | i18n }} +
  • +
+
+ + + + {{ "turnOn" | i18n }} + +
+ + +
+ + {{ "centralizeDataOwnershipWarningDesc" | i18n }} + + + {{ "centralizeDataOwnershipWarningLink" | i18n }} + + +
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts index 59670457d88..e1b2f14d457 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.ts @@ -1,18 +1,30 @@ -import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core"; -import { lastValueFrom } from "rxjs"; +import { + ChangeDetectionStrategy, + Component, + OnInit, + signal, + Signal, + TemplateRef, + viewChild, + WritableSignal, +} from "@angular/core"; +import { Observable } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrgKey } from "@bitwarden/common/types/key"; -import { CenterPositionStrategy, DialogService } from "@bitwarden/components"; import { EncString } from "@bitwarden/sdk-internal"; import { SharedModule } from "../../../../shared"; import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component"; +import { OrganizationDataOwnershipPolicyDialogComponent } from "../policy-edit-dialogs"; -interface VNextPolicyRequest { +export interface VNextPolicyRequest { policy: PolicyRequest; metadata: { defaultUserCollectionName: string; @@ -20,11 +32,17 @@ interface VNextPolicyRequest { } export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefinition { - name = "organizationDataOwnership"; - description = "organizationDataOwnershipDesc"; + name = "centralizeDataOwnership"; + description = "centralizeDataOwnershipDesc"; type = PolicyType.OrganizationDataOwnership; component = vNextOrganizationDataOwnershipPolicyComponent; showDescription = false; + + editDialogComponent = OrganizationDataOwnershipPolicyDialogComponent; + + override display$(organization: Organization, configService: ConfigService): Observable { + return configService.getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems); + } } @Component({ @@ -38,27 +56,16 @@ export class vNextOrganizationDataOwnershipPolicyComponent implements OnInit { constructor( - private dialogService: DialogService, private i18nService: I18nService, private encryptService: EncryptService, ) { super(); } + private readonly policyForm: Signal | undefined> = viewChild("step0"); + private readonly warningContent: Signal | undefined> = viewChild("step1"); + protected readonly step: WritableSignal = signal(0); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild("dialog", { static: true }) warningContent!: TemplateRef; - - override async confirm(): Promise { - if (this.policyResponse?.enabled && !this.enabled.value) { - const dialogRef = this.dialogService.open(this.warningContent, { - positionStrategy: new CenterPositionStrategy(), - }); - const result = await lastValueFrom(dialogRef.closed); - return Boolean(result); - } - return true; - } + protected steps = [this.policyForm, this.warningContent]; async buildVNextRequest(orgKey: OrgKey): Promise { if (!this.policy) { @@ -90,4 +97,8 @@ export class vNextOrganizationDataOwnershipPolicyComponent return encrypted.encryptedString; } + + setStep(step: number) { + this.step.set(step); + } } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts index c633ff5f421..f1b3d04cc7a 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialog.component.ts @@ -16,6 +16,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; import { DIALOG_DATA, DialogConfig, @@ -28,7 +29,7 @@ import { KeyService } from "@bitwarden/key-management"; import { SharedModule } from "../../../shared"; import { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component"; -import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions/vnext-organization-data-ownership.component"; +import { VNextPolicyRequest } from "./policy-edit-definitions/organization-data-ownership.component"; export type PolicyEditDialogData = { /** @@ -73,13 +74,24 @@ export class PolicyEditDialogComponent implements AfterViewInit { private formBuilder: FormBuilder, protected dialogRef: DialogRef, protected toastService: ToastService, - private keyService: KeyService, + protected keyService: KeyService, ) {} get policy(): BasePolicyEditDefinition { return this.data.policy; } + /** + * Type guard to check if the policy component has the buildVNextRequest method. + */ + private hasVNextRequest( + component: BasePolicyEditComponent, + ): component is BasePolicyEditComponent & { + buildVNextRequest: (orgKey: OrgKey) => Promise; + } { + return "buildVNextRequest" in component && typeof component.buildVNextRequest === "function"; + } + /** * Instantiates the child policy component and inserts it into the view. */ @@ -129,7 +141,7 @@ export class PolicyEditDialogComponent implements AfterViewInit { } try { - if (this.policyComponent instanceof vNextOrganizationDataOwnershipPolicyComponent) { + if (this.hasVNextRequest(this.policyComponent)) { await this.handleVNextSubmission(this.policyComponent); } else { await this.handleStandardSubmission(); @@ -158,7 +170,9 @@ export class PolicyEditDialogComponent implements AfterViewInit { } private async handleVNextSubmission( - policyComponent: vNextOrganizationDataOwnershipPolicyComponent, + policyComponent: BasePolicyEditComponent & { + buildVNextRequest: (orgKey: OrgKey) => Promise; + }, ): Promise { const orgKey = await firstValueFrom( this.accountService.activeAccount$.pipe( @@ -173,12 +187,12 @@ export class PolicyEditDialogComponent implements AfterViewInit { throw new Error("No encryption key for this organization."); } - const vNextRequest = await policyComponent.buildVNextRequest(orgKey); + const request = await policyComponent.buildVNextRequest(orgKey); await this.policyApiService.putPolicyVNext( this.data.organizationId, this.data.policy.type, - vNextRequest, + request, ); } static open = (dialogService: DialogService, config: DialogConfig) => { diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.html similarity index 100% rename from apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html rename to apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.html diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts similarity index 94% rename from apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts rename to apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts index 9dfb8ebb7e7..fbdeffc71bb 100644 --- a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts @@ -41,20 +41,15 @@ import { } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; -import { SharedModule } from "../../../shared"; - -import { AutoConfirmPolicyEditComponent } from "./policy-edit-definitions/auto-confirm-policy.component"; +import { SharedModule } from "../../../../shared"; +import { AutoConfirmPolicyEditComponent } from "../policy-edit-definitions/auto-confirm-policy.component"; import { PolicyEditDialogComponent, PolicyEditDialogData, PolicyEditDialogResult, -} from "./policy-edit-dialog.component"; +} from "../policy-edit-dialog.component"; -export type MultiStepSubmit = { - sideEffect: () => Promise; - footerContent: Signal | undefined>; - titleContent: Signal | undefined>; -}; +import { MultiStepSubmit } from "./models"; export type AutoConfirmPolicyDialogData = PolicyEditDialogData & { firstTimeDialog?: boolean; @@ -202,6 +197,7 @@ export class AutoConfirmPolicyDialogComponent } const autoConfirmRequest = await this.policyComponent.buildRequest(); + await this.policyApiService.putPolicy( this.data.organizationId, this.data.policy.type, @@ -235,7 +231,7 @@ export class AutoConfirmPolicyDialogComponent data: null, }; - await this.policyApiService.putPolicy( + await this.policyApiService.putPolicyVNext( this.data.organizationId, PolicyType.SingleOrg, singleOrgRequest, @@ -260,7 +256,10 @@ export class AutoConfirmPolicyDialogComponent try { const multiStepSubmit = await firstValueFrom(this.multiStepSubmit); - await multiStepSubmit[this.currentStep()].sideEffect(); + const sideEffect = multiStepSubmit[this.currentStep()].sideEffect; + if (sideEffect) { + await sideEffect(); + } if (this.currentStep() === multiStepSubmit.length - 1) { this.dialogRef.close("saved"); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/index.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/index.ts new file mode 100644 index 00000000000..307d0da04b0 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/index.ts @@ -0,0 +1,3 @@ +export * from "./auto-confirm-edit-policy-dialog.component"; +export * from "./organization-data-ownership-edit-policy-dialog.component"; +export * from "./models"; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/models.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/models.ts new file mode 100644 index 00000000000..86120623701 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/models.ts @@ -0,0 +1,7 @@ +import { Signal, TemplateRef } from "@angular/core"; + +export type MultiStepSubmit = { + sideEffect?: () => Promise; + footerContent: Signal | undefined>; + titleContent: Signal | undefined>; +}; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.html new file mode 100644 index 00000000000..73691e94199 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.html @@ -0,0 +1,72 @@ +
+ + + @let title = multiStepSubmit()[currentStep()]?.titleContent(); + @if (title) { + + } + + + + @if (loading) { +
+ + {{ "loading" | i18n }} +
+ } +
+ @if (policy.showDescription) { +

{{ policy.description | i18n }}

+ } +
+ +
+ + @let footer = multiStepSubmit()[currentStep()]?.footerContent(); + @if (footer) { + + } + +
+
+ + + {{ policy.name | i18n }} + + + + {{ "centralizeDataOwnershipWarningTitle" | i18n }} + + + + + + + + + + + + diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.ts new file mode 100644 index 00000000000..7869eab0063 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/organization-data-ownership-edit-policy-dialog.component.ts @@ -0,0 +1,224 @@ +import { + AfterViewInit, + ChangeDetectorRef, + Component, + Inject, + signal, + TemplateRef, + viewChild, + WritableSignal, +} from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { + catchError, + combineLatest, + defer, + firstValueFrom, + from, + map, + Observable, + of, + startWith, + switchMap, +} from "rxjs"; + +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.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 { assertNonNullish } from "@bitwarden/common/auth/utils"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +import { SharedModule } from "../../../../shared"; +import { vNextOrganizationDataOwnershipPolicyComponent } from "../policy-edit-definitions"; +import { + PolicyEditDialogComponent, + PolicyEditDialogData, + PolicyEditDialogResult, +} from "../policy-edit-dialog.component"; + +import { MultiStepSubmit } from "./models"; + +/** + * Custom policy dialog component for Centralize Organization Data + * Ownership policy. Satisfies the PolicyDialogComponent interface + * structurally via its static open() function. + */ +// 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: "organization-data-ownership-edit-policy-dialog.component.html", + imports: [SharedModule], +}) +export class OrganizationDataOwnershipPolicyDialogComponent + extends PolicyEditDialogComponent + implements AfterViewInit +{ + policyType = PolicyType; + + protected centralizeDataOwnershipEnabled$: Observable = defer(() => + from( + this.policyApiService.getPolicy( + this.data.organizationId, + PolicyType.OrganizationDataOwnership, + ), + ).pipe( + map((policy) => policy.enabled), + catchError(() => of(false)), + ), + ); + + protected readonly currentStep: WritableSignal = signal(0); + protected readonly multiStepSubmit: WritableSignal = signal([]); + + private readonly policyForm = viewChild.required>("step0"); + private readonly warningContent = viewChild.required>("step1"); + private readonly policyFormTitle = viewChild.required>("step0Title"); + private readonly warningTitle = viewChild.required>("step1Title"); + + override policyComponent: vNextOrganizationDataOwnershipPolicyComponent | undefined; + + constructor( + @Inject(DIALOG_DATA) protected data: PolicyEditDialogData, + accountService: AccountService, + policyApiService: PolicyApiServiceAbstraction, + i18nService: I18nService, + cdr: ChangeDetectorRef, + formBuilder: FormBuilder, + dialogRef: DialogRef, + toastService: ToastService, + protected keyService: KeyService, + ) { + super( + data, + accountService, + policyApiService, + i18nService, + cdr, + formBuilder, + dialogRef, + toastService, + keyService, + ); + } + + async ngAfterViewInit() { + await super.ngAfterViewInit(); + + if (this.policyComponent) { + this.saveDisabled$ = combineLatest([ + this.centralizeDataOwnershipEnabled$, + this.policyComponent.enabled.valueChanges.pipe( + startWith(this.policyComponent.enabled.value), + ), + ]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value)); + } + + this.multiStepSubmit.set(this.buildMultiStepSubmit()); + } + + private buildMultiStepSubmit(): MultiStepSubmit[] { + if (this.policyComponent?.policyResponse?.enabled) { + return [ + { + sideEffect: () => this.handleSubmit(), + footerContent: this.policyForm, + titleContent: this.policyFormTitle, + }, + ]; + } + + return [ + { + footerContent: this.policyForm, + titleContent: this.policyFormTitle, + }, + { + sideEffect: () => this.handleSubmit(), + footerContent: this.warningContent, + titleContent: this.warningTitle, + }, + ]; + } + + private async handleSubmit() { + if (!this.policyComponent) { + throw new Error("PolicyComponent not initialized."); + } + + const orgKey = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.keyService.orgKeys$(userId)), + ), + ); + + assertNonNullish(orgKey, "Org key not provided"); + + const request = await this.policyComponent.buildVNextRequest( + orgKey[this.data.organizationId as OrganizationId], + ); + + await this.policyApiService.putPolicyVNext( + this.data.organizationId, + this.data.policy.type, + request, + ); + + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)), + }); + + if (!this.policyComponent.enabled.value) { + this.dialogRef.close("saved"); + } + } + + submit = async () => { + if (!this.policyComponent) { + throw new Error("PolicyComponent not initialized."); + } + + if ((await this.policyComponent.confirm()) == false) { + this.dialogRef.close(); + return; + } + + try { + const sideEffect = this.multiStepSubmit()[this.currentStep()].sideEffect; + if (sideEffect) { + await sideEffect(); + } + + if (this.currentStep() === this.multiStepSubmit().length - 1) { + this.dialogRef.close("saved"); + return; + } + + this.currentStep.update((value) => value + 1); + this.policyComponent.setStep(this.currentStep()); + } catch (error: any) { + this.toastService.showToast({ + variant: "error", + message: error.message, + }); + } + }; + + static open = (dialogService: DialogService, config: DialogConfig) => { + return dialogService.open( + OrganizationDataOwnershipPolicyDialogComponent, + config, + ); + }; +} 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/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts index 1bbaa0ec236..b09b5f0bc9a 100644 --- a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts +++ b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts @@ -3,6 +3,7 @@ import { BehaviorSubject, of } from "rxjs"; import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { + InitializeJitPasswordCredentials, SetInitialPasswordCredentials, SetInitialPasswordService, SetInitialPasswordUserType, @@ -20,11 +21,13 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management"; import { RouterService } from "@bitwarden/web-vault/app/core"; @@ -47,6 +50,7 @@ describe("WebSetInitialPasswordService", () => { let organizationInviteService: MockProxy; let routerService: MockProxy; let accountCryptographicStateService: MockProxy; + let registerSdkService: MockProxy; beforeEach(() => { apiService = mock(); @@ -62,6 +66,7 @@ describe("WebSetInitialPasswordService", () => { organizationInviteService = mock(); routerService = mock(); accountCryptographicStateService = mock(); + registerSdkService = mock(); sut = new WebSetInitialPasswordService( apiService, @@ -77,6 +82,7 @@ describe("WebSetInitialPasswordService", () => { organizationInviteService, routerService, accountCryptographicStateService, + registerSdkService, ); }); @@ -208,4 +214,36 @@ describe("WebSetInitialPasswordService", () => { }); }); }); + + describe("initializePasswordJitPasswordUserV2Encryption(...)", () => { + it("should call routerService.getAndClearLoginRedirectUrl() and organizationInviteService.clearOrganizationInvitation()", async () => { + // Arrange + const credentials: InitializeJitPasswordCredentials = { + newPasswordHint: "newPasswordHint", + orgSsoIdentifier: "orgSsoIdentifier", + orgId: "orgId" as OrganizationId, + resetPasswordAutoEnroll: false, + newPassword: "newPassword123!", + salt: "user@example.com" as MasterPasswordSalt, + }; + const userId = "userId" as UserId; + + const superSpy = jest + .spyOn( + Object.getPrototypeOf(Object.getPrototypeOf(sut)), + "initializePasswordJitPasswordUserV2Encryption", + ) + .mockResolvedValue(undefined); + + // Act + await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId); + + // Assert + expect(superSpy).toHaveBeenCalledWith(credentials, userId); + expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalledTimes(1); + expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1); + + superSpy.mockRestore(); + }); + }); }); diff --git a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts index 303b9148e8e..0b8dba6c40e 100644 --- a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts +++ b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts @@ -1,6 +1,7 @@ import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation"; import { + InitializeJitPasswordCredentials, SetInitialPasswordCredentials, SetInitialPasswordService, SetInitialPasswordUserType, @@ -14,6 +15,7 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; import { UserId } from "@bitwarden/common/types/guid"; import { KdfConfigService, KeyService } from "@bitwarden/key-management"; import { RouterService } from "@bitwarden/web-vault/app/core"; @@ -36,6 +38,7 @@ export class WebSetInitialPasswordService private organizationInviteService: OrganizationInviteService, private routerService: RouterService, protected accountCryptographicStateService: AccountCryptographicStateService, + protected registerSdkService: RegisterSdkService, ) { super( apiService, @@ -49,6 +52,7 @@ export class WebSetInitialPasswordService organizationUserApiService, userDecryptionOptionsService, accountCryptographicStateService, + registerSdkService, ); } @@ -83,4 +87,15 @@ export class WebSetInitialPasswordService await this.routerService.getAndClearLoginRedirectUrl(); await this.organizationInviteService.clearOrganizationInvitation(); } + + override async initializePasswordJitPasswordUserV2Encryption( + credentials: InitializeJitPasswordCredentials, + userId: UserId, + ): Promise { + await super.initializePasswordJitPasswordUserV2Encryption(credentials, userId); + + // TODO: Investigate refactoring the following logic in https://bitwarden.atlassian.net/browse/PM-22615 + await this.routerService.getAndClearLoginRedirectUrl(); + await this.organizationInviteService.clearOrganizationInvitation(); + } } diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 661d14502fe..7b248eee8a3 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -6,11 +6,11 @@ import { Router } from "@angular/router"; import { CollectionAdminService, - DefaultCollectionAdminService, - OrganizationUserApiService, CollectionService, - OrganizationUserService, + DefaultCollectionAdminService, DefaultOrganizationUserService, + OrganizationUserApiService, + OrganizationUserService, } from "@bitwarden/admin-console/common"; import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth/device-management/default-device-management-component.service"; import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; @@ -27,17 +27,17 @@ import { OBSERVABLE_DISK_LOCAL_STORAGE, OBSERVABLE_DISK_STORAGE, OBSERVABLE_MEMORY_STORAGE, + SafeInjectionToken, SECURE_STORAGE, SYSTEM_LANGUAGE, - SafeInjectionToken, WINDOW, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { - RegistrationFinishService as RegistrationFinishServiceAbstraction, LoginComponentService, - SsoComponentService, LoginDecryptionOptionsService, + RegistrationFinishService as RegistrationFinishServiceAbstraction, + SsoComponentService, TwoFactorAuthDuoComponentService, } from "@bitwarden/auth/angular"; import { @@ -90,6 +90,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor 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 { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service"; import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; @@ -120,9 +121,9 @@ import { DialogService, ToastService } from "@bitwarden/components"; import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { + BiometricsService, KdfConfigService, KeyService as KeyServiceAbstraction, - BiometricsService, } from "@bitwarden/key-management"; import { LockComponentService, @@ -135,17 +136,17 @@ import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/va import { flagEnabled } from "../../utils/flags"; import { - POLICY_EDIT_REGISTER, ossPolicyEditRegister, + POLICY_EDIT_REGISTER, } from "../admin-console/organizations/policies"; import { + LinkSsoService, WebChangePasswordService, - WebRegistrationFinishService, WebLoginComponentService, WebLoginDecryptionOptionsService, - WebTwoFactorAuthDuoComponentService, - LinkSsoService, + WebRegistrationFinishService, WebSetInitialPasswordService, + WebTwoFactorAuthDuoComponentService, } from "../auth"; import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service"; import { WebPremiumInterestStateService } from "../billing/services/premium-interest/web-premium-interest-state.service"; @@ -320,6 +321,7 @@ const safeProviders: SafeProvider[] = [ OrganizationInviteService, RouterService, AccountCryptographicStateService, + RegisterSdkService, ], }), safeProvider({ 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.html b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html index 05758a854c2..9a99a55b77b 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html @@ -31,81 +31,75 @@ - + - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - - + + {{ "name" | i18n }} + {{ "owner" | i18n }} + - - - - - - - - - {{ r.name }} - - - {{ r.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
- {{ r.subTitle }} - - - - - - - - {{ "instructions" | i18n }} - - -
-
+ + + + + + + {{ row.name }} + + + {{ row.name }} + + + + {{ "shared" | i18n }} + + + + {{ "attachments" | i18n }} + +
+ {{ row.subTitle }} + + + + + + + + {{ "instructions" | i18n }} + +
+
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.html b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html index 5dd11b59999..cc7537333ad 100644 --- a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html @@ -32,68 +32,63 @@ - + - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - - + + {{ "name" | i18n }} + {{ "owner" | i18n }} - - - - - - - - {{ r.name }} - - - {{ r.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
- {{ r.subTitle }} - - - + + + + + + {{ row.name }} - - - + + + {{ row.name }} + + + + {{ "shared" | i18n }} + + + + {{ "attachments" | i18n }} + +
+ {{ row.subTitle }} + + + + +
-
+
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-layout.component.html b/apps/web/src/app/dirt/reports/reports-layout.component.html index a27556a7aa9..0cb5d304a34 100644 --- a/apps/web/src/app/dirt/reports/reports-layout.component.html +++ b/apps/web/src/app/dirt/reports/reports-layout.component.html @@ -2,8 +2,10 @@ diff --git a/apps/web/src/app/dirt/reports/reports-layout.component.ts b/apps/web/src/app/dirt/reports/reports-layout.component.ts index c2fbf858590..a6d84ccb037 100644 --- a/apps/web/src/app/dirt/reports/reports-layout.component.ts +++ b/apps/web/src/app/dirt/reports/reports-layout.component.ts @@ -1,6 +1,6 @@ -import { Component, OnDestroy } from "@angular/core"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; -import { Subscription } from "rxjs"; import { filter } from "rxjs/operators"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -10,20 +10,20 @@ import { filter } from "rxjs/operators"; templateUrl: "reports-layout.component.html", standalone: false, }) -export class ReportsLayoutComponent implements OnDestroy { +export class ReportsLayoutComponent { homepage = true; - subscription: Subscription; constructor(router: Router) { - this.subscription = router.events - .pipe(filter((event) => event instanceof NavigationEnd)) - // eslint-disable-next-line rxjs-angular/prefer-takeuntil + const reportsHomeRoute = "/reports"; + + this.homepage = router.url === reportsHomeRoute; + router.events + .pipe( + takeUntilDestroyed(), + filter((event) => event instanceof NavigationEnd), + ) .subscribe((event) => { - this.homepage = (event as NavigationEnd).url == "/reports"; + this.homepage = (event as NavigationEnd).url == reportsHomeRoute; }); } - - ngOnDestroy(): void { - this.subscription?.unsubscribe(); - } } 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/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/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/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 8a6f720bb45..a40cb3d4330 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -19,7 +19,7 @@ @if (SendUIRefresh$ | async) { -
+
- @if (userCanArchive$ | async) { + @if ((userCanArchive$ | async) && !params.isAdminConsoleAction) { @if (isCipherArchived) { } } - + @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 0aabc98d669..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 @@ -28,8 +28,7 @@ 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"; @@ -250,6 +249,15 @@ describe("VaultItemDialogComponent", () => { }); 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); 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 8e322b6fbd6..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"; @@ -63,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"; @@ -257,7 +257,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { } protected get showActionButtons() { - return this.cipher !== null && this.params.mode === "form" && this.formConfig.mode !== "clone"; + return this.cipher !== null && this.formConfig.mode !== "clone"; } /** diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 5b9f9db3e62..081829a8d83 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -171,37 +171,47 @@ - @if (!viewingOrgVault) { - } - - - - - + @if (!isDeleted && canEditCipher) { + + } + @if (showAttachments) { + + } + @if (showClone) { + + } + @if (showAssignToCollections) { + + } + @if (showEventLogs) { + + } @if (showArchiveButton) { @if (userCanArchive) { - + @if (bulkArchiveAllowed) { + + } - + @if (bulkUnarchiveAllowed) { + + } + + +
+ + + {{ "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) { + + } - - - } @@ -61,7 +58,6 @@ @if (breadcrumb.route(); as route) { diff --git a/libs/components/src/color-password/color-password.component.ts b/libs/components/src/color-password/color-password.component.ts index eaaefd29f1d..3bf4d3d9983 100644 --- a/libs/components/src/color-password/color-password.component.ts +++ b/libs/components/src/color-password/color-password.component.ts @@ -1,5 +1,14 @@ -import { Component, computed, HostBinding, input } from "@angular/core"; +import { + Component, + computed, + ElementRef, + HostBinding, + HostListener, + inject, + input, +} from "@angular/core"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; type CharacterType = "letter" | "emoji" | "special" | "number"; @@ -14,7 +23,7 @@ type CharacterType = "letter" | "emoji" | "special" | "number"; @Component({ selector: "bit-color-password", template: `@for (character of passwordCharArray(); track $index; let i = $index) { - + {{ character }} @if (showCount()) { {{ i + 1 }} @@ -31,6 +40,9 @@ export class ColorPasswordComponent { return Array.from(this.password() ?? ""); }); + private platformUtilsService = inject(PlatformUtilsService); + private elementRef = inject(ElementRef); + characterStyles: Record = { emoji: [], letter: ["tw-text-main"], @@ -78,4 +90,28 @@ export class ColorPasswordComponent { return "letter"; } + + @HostListener("copy", ["$event"]) + onCopy(event: ClipboardEvent) { + event.preventDefault(); + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + + const spanElements = this.elementRef.nativeElement.querySelectorAll( + "span[data-password-character]", + ); + let copiedText = ""; + + spanElements.forEach((span: HTMLElement, index: number) => { + if (selection.containsNode(span, true)) { + copiedText += this.passwordCharArray()[index]; + } + }); + + if (copiedText) { + this.platformUtilsService.copyToClipboard(copiedText); + } + } } diff --git a/libs/components/src/color-password/color-password.stories.ts b/libs/components/src/color-password/color-password.stories.ts index 2ed5cdc4b8d..f00b3a4acf5 100644 --- a/libs/components/src/color-password/color-password.stories.ts +++ b/libs/components/src/color-password/color-password.stories.ts @@ -1,4 +1,6 @@ -import { Meta, StoryObj } from "@storybook/angular"; +import { applicationConfig, Meta, StoryObj } from "@storybook/angular"; + +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet"; @@ -9,6 +11,19 @@ const examplePassword = "Wq$Jk😀7jlI DX#rS5Sdi!z0O "; export default { title: "Component Library/Color Password", component: ColorPasswordComponent, + decorators: [ + applicationConfig({ + providers: [ + { + provide: PlatformUtilsService, + useValue: { + // eslint-disable-next-line + copyToClipboard: (text: string) => console.log(`${text} copied to clipboard`), + }, + }, + ], + }), + ], args: { password: examplePassword, showCount: false, diff --git a/libs/components/src/dialog/dialog.service.ts b/libs/components/src/dialog/dialog.service.ts index 88dee499e07..8393db57b2f 100644 --- a/libs/components/src/dialog/dialog.service.ts +++ b/libs/components/src/dialog/dialog.service.ts @@ -10,7 +10,7 @@ import { ComponentPortal, Portal } from "@angular/cdk/portal"; import { Injectable, Injector, TemplateRef, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; -import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs"; +import { filter, firstValueFrom, map, Observable, Subject, switchMap, take } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -62,7 +62,7 @@ export abstract class DialogRef implements Pick< export type DialogConfig = Pick< CdkDialogConfig, - "data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" + "data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" | "restoreFocus" >; /** @@ -242,6 +242,11 @@ export class DialogService { }; ref.cdkDialogRefBase = this.dialog.open(componentOrTemplateRef, _config); + + if (config?.restoreFocus === undefined) { + this.setRestoreFocusEl(ref); + } + return ref; } @@ -305,6 +310,48 @@ export class DialogService { return this.activeDrawer?.close(); } + /** + * Configure the dialog to return focus to the previous active element upon closing. + * @param ref CdkDialogRef + * + * The cdk dialog already has the optional directive `cdkTrapFocusAutoCapture` to capture the + * current active element and return focus to it upon close. However, it does not have a way to + * delay the capture of the element. We need this delay in some situations, where the active + * element may be changing as the dialog is opening, and we want to wait for that to settle. + * + * For example -- the menu component often contains menu items that open dialogs. When the dialog + * opens, the menu is closing and is setting focus back to the menu trigger since the menu item no + * longer exists. We want to capture the menu trigger as the active element, not the about-to-be- + * nonexistent menu item. If we wait a tick, we can let the menu finish that focus move. + */ + private setRestoreFocusEl(ref: CdkDialogRef) { + /** + * First, capture the current active el with no delay so that we can support normal use cases + * where we are not doing manual focus management + */ + const activeEl = document.activeElement; + + const restoreFocusTimeout = setTimeout(() => { + let restoreFocusEl = activeEl; + + /** + * If the original active element is no longer connected, it's because we purposely removed it + * from the DOM and have moved focus. Select the new active element instead. + */ + if (!restoreFocusEl?.isConnected) { + restoreFocusEl = document.activeElement; + } + + if (restoreFocusEl instanceof HTMLElement) { + ref.cdkDialogRefBase.config.restoreFocus = restoreFocusEl; + } + }, 0); + + ref.closed.pipe(take(1)).subscribe(() => { + clearTimeout(restoreFocusTimeout); + }); + } + /** The injector that is passed to the opened dialog */ private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector { return Injector.create({ diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 58364dfd045..f81f0594218 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -6,7 +6,6 @@ isDrawer ? 'tw-h-full tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg', ]" cdkTrapFocus - cdkTrapFocusAutoCapture > @let showHeaderBorder = bodyHasScrolledFrom().top;
{{ title() }} @if (subtitle(); as subtitleText) { diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts index 0f9f341763a..f9073da2217 100644 --- a/libs/components/src/dialog/dialog/dialog.component.ts +++ b/libs/components/src/dialog/dialog/dialog.component.ts @@ -11,6 +11,7 @@ import { DestroyRef, computed, signal, + AfterViewInit, } from "@angular/core"; import { toObservable } from "@angular/core/rxjs-interop"; import { combineLatest, switchMap } from "rxjs"; @@ -62,8 +63,10 @@ const drawerSizeToWidth = { SpinnerComponent, ], }) -export class DialogComponent { +export class DialogComponent implements AfterViewInit { private readonly destroyRef = inject(DestroyRef); + private readonly dialogHeader = + viewChild.required>("dialogHeader"); private readonly scrollableBody = viewChild.required(CdkScrollable); private readonly scrollBottom = viewChild.required>("scrollBottom"); @@ -141,6 +144,22 @@ export class DialogComponent { return [...baseClasses, this.width(), ...sizeClasses, ...animationClasses]; }); + ngAfterViewInit() { + /** + * Wait a tick for any focus management to occur on the trigger element before moving focus to + * the dialog header. We choose the dialog header because it is always present, unlike possible + * interactive elements. + * + * We are doing this manually instead of using `cdkTrapFocusAutoCapture` and `cdkFocusInitial` + * because we need this delay behavior. + */ + const headerFocusTimeout = setTimeout(() => { + this.dialogHeader().nativeElement.focus(); + }, 0); + + this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout)); + } + handleEsc(event: Event) { if (!this.dialogRef?.disableClose) { this.dialogRef?.close(); diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index 255799b6690..66bfcafafe9 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -1,5 +1,5 @@ @let mainContentId = "main-content"; -
+
diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts index 7460099cf92..5e3d420c8e5 100644 --- a/libs/components/src/layout/layout.component.ts +++ b/libs/components/src/layout/layout.component.ts @@ -1,7 +1,7 @@ import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y"; import { PortalModule } from "@angular/cdk/portal"; import { CommonModule } from "@angular/common"; -import { Component, ElementRef, inject, viewChild } from "@angular/core"; +import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core"; import { RouterModule } from "@angular/router"; import { DrawerHostDirective } from "../drawer/drawer-host.directive"; @@ -38,6 +38,12 @@ export class LayoutComponent { protected drawerPortal = inject(DrawerService).portal; private readonly mainContent = viewChild.required>("main"); + + /** + * Rounded top left corner for the main content area + */ + readonly rounded = input(false, { transform: booleanAttribute }); + protected focusMainContent() { this.mainContent().nativeElement.focus(); } diff --git a/libs/components/src/layout/layout.stories.ts b/libs/components/src/layout/layout.stories.ts index 59770c21d2e..75ae329a1b3 100644 --- a/libs/components/src/layout/layout.stories.ts +++ b/libs/components/src/layout/layout.stories.ts @@ -14,6 +14,8 @@ import { StorybookGlobalStateProvider } from "../utils/state-mock"; import { LayoutComponent } from "./layout.component"; import { mockLayoutI18n } from "./mocks"; +import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet"; + export default { title: "Component Library/Layout", component: LayoutComponent, @@ -63,7 +65,7 @@ export const WithContent: Story = { render: (args) => ({ props: args, template: /* HTML */ ` - + (args)}> @@ -111,3 +113,10 @@ export const Secondary: Story = { `, }), }; + +export const Rounded: Story = { + ...WithContent, + args: { + rounded: true, + }, +}; diff --git a/libs/components/src/link/link.directive.ts b/libs/components/src/link/link.directive.ts index e6de8ac8402..62f0d8b878f 100644 --- a/libs/components/src/link/link.directive.ts +++ b/libs/components/src/link/link.directive.ts @@ -3,21 +3,34 @@ import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } f import { AriaDisableDirective } from "../a11y"; import { ariaDisableElement } from "../utils"; -export type LinkType = "primary" | "secondary" | "contrast" | "light"; +export const LinkTypes = [ + "primary", + "secondary", + "contrast", + "light", + "default", + "subtle", + "success", + "warning", + "danger", +] as const; + +export type LinkType = (typeof LinkTypes)[number]; const linkStyles: Record = { - primary: [ - "!tw-text-primary-600", - "hover:!tw-text-primary-700", - "focus-visible:before:tw-ring-primary-600", - ], - secondary: ["!tw-text-main", "hover:!tw-text-main", "focus-visible:before:tw-ring-primary-600"], + primary: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"], + default: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"], + secondary: ["tw-text-fg-heading", "hover:tw-text-fg-heading"], + light: ["tw-text-fg-white", "hover:tw-text-fg-white", "focus-visible:before:tw-ring-fg-contrast"], + subtle: ["!tw-text-fg-heading", "hover:tw-text-fg-heading"], + success: ["tw-text-fg-success", "hover:tw-text-fg-success-strong"], + warning: ["tw-text-fg-warning", "hover:tw-text-fg-warning-strong"], + danger: ["tw-text-fg-danger", "hover:tw-text-fg-danger-strong"], contrast: [ - "!tw-text-contrast", - "hover:!tw-text-contrast", - "focus-visible:before:tw-ring-text-contrast", + "tw-text-fg-contrast", + "hover:tw-text-fg-contrast", + "focus-visible:before:tw-ring-fg-contrast", ], - light: ["!tw-text-alt2", "hover:!tw-text-alt2", "focus-visible:before:tw-ring-text-alt2"], }; const commonStyles = [ @@ -32,16 +45,18 @@ const commonStyles = [ "tw-rounded", "tw-transition", "tw-no-underline", + "tw-cursor-pointer", "hover:tw-underline", "hover:tw-decoration-1", "disabled:tw-no-underline", "disabled:tw-cursor-not-allowed", - "disabled:!tw-text-secondary-300", - "disabled:hover:!tw-text-secondary-300", + "disabled:!tw-text-fg-disabled", + "disabled:hover:!tw-text-fg-disabled", "disabled:hover:tw-no-underline", "focus-visible:tw-outline-none", "focus-visible:tw-underline", "focus-visible:tw-decoration-1", + "focus-visible:before:tw-ring-border-focus", // Workaround for html button tag not being able to be set to `display: inline` // and at the same time not being able to use `tw-ring-offset` because of box-shadow issue. @@ -63,14 +78,14 @@ const commonStyles = [ "focus-visible:tw-z-10", "aria-disabled:tw-no-underline", "aria-disabled:tw-pointer-events-none", - "aria-disabled:!tw-text-secondary-300", - "aria-disabled:hover:!tw-text-secondary-300", + "aria-disabled:!tw-text-fg-disabled", + "aria-disabled:hover:!tw-text-fg-disabled", "aria-disabled:hover:tw-no-underline", ]; @Directive() abstract class LinkDirective { - readonly linkType = input("primary"); + readonly linkType = input("default"); } /** diff --git a/libs/components/src/link/link.mdx b/libs/components/src/link/link.mdx index 072e0dd84d8..4954effb6c0 100644 --- a/libs/components/src/link/link.mdx +++ b/libs/components/src/link/link.mdx @@ -18,10 +18,15 @@ import { LinkModule } from "@bitwarden/components"; You can use one of the following variants by providing it as the `linkType` input: -- `primary` - most common, uses brand color -- `secondary` - matches the main text color +- @deprecated `primary` => use `default` instead +- @deprecated `secondary` => use `subtle` instead +- `default` - most common, uses brand color +- `subtle` - matches the main text color - `contrast` - for high contrast against a dark background (or a light background in dark mode) - `light` - always a light color, even in dark mode +- `warning` - used in association with warning callouts/banners +- `success` - used in association with success callouts/banners +- `danger` - used in association with danger callouts/banners ## Sizes diff --git a/libs/components/src/link/link.stories.ts b/libs/components/src/link/link.stories.ts index ae91c9be108..d27c4f74332 100644 --- a/libs/components/src/link/link.stories.ts +++ b/libs/components/src/link/link.stories.ts @@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet"; -import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive"; +import { AnchorLinkDirective, ButtonLinkDirective, LinkTypes } from "./link.directive"; import { LinkModule } from "./link.module"; export default { @@ -14,7 +14,7 @@ export default { ], argTypes: { linkType: { - options: ["primary", "secondary", "contrast"], + options: LinkTypes.map((type) => type), control: { type: "radio" }, }, }, @@ -30,48 +30,153 @@ type Story = StoryObj; export const Default: Story = { render: (args) => ({ + props: { + linkType: args.linkType, + backgroundClass: + args.linkType === "contrast" + ? "tw-bg-bg-contrast" + : args.linkType === "light" + ? "tw-bg-bg-brand" + : "tw-bg-transparent", + }, template: /*html*/ ` - (args)}>Your text here + `, }), + args: { + linkType: "primary", + }, + parameters: { + chromatic: { disableSnapshot: true }, + }, +}; + +export const AllVariations: Story = { + render: () => ({ + template: /*html*/ ` +
+
+ Primary +
+
+ Secondary +
+
+ Contrast +
+
+ Light +
+
+ Default +
+
+ Subtle +
+
+ Success +
+
+ Warning +
+
+ Danger +
+
+ `, + }), + parameters: { + controls: { + exclude: ["linkType"], + hideNoControlsWarning: true, + }, + }, }; export const InteractionStates: Story = { render: () => ({ template: /*html*/ ` -
+
+ -
+ -
+ - `, }), + parameters: { + controls: { + exclude: ["linkType"], + hideNoControlsWarning: true, + }, + }, }; export const Buttons: Story = { render: (args) => ({ - props: args, + props: { + linkType: args.linkType, + backgroundClass: + args.linkType === "contrast" + ? "tw-bg-bg-contrast" + : args.linkType === "light" + ? "tw-bg-bg-brand" + : "tw-bg-transparent", + }, template: /*html*/ ` -
+
@@ -100,9 +205,17 @@ export const Buttons: Story = { export const Anchors: StoryObj = { render: (args) => ({ - props: args, + props: { + linkType: args.linkType, + backgroundClass: + args.linkType === "contrast" + ? "tw-bg-bg-contrast" + : args.linkType === "light" + ? "tw-bg-bg-brand" + : "tw-bg-transparent", + }, template: /*html*/ ` -
+
@@ -138,18 +251,15 @@ export const Inline: Story = { `, }), - args: { - linkType: "primary", - }, }; -export const Disabled: Story = { +export const Inactive: Story = { render: (args) => ({ props: args, template: /*html*/ ` -
+
`, diff --git a/libs/components/src/menu/menu-trigger-for.directive.ts b/libs/components/src/menu/menu-trigger-for.directive.ts index 1d79fbc9768..6306f3326d6 100644 --- a/libs/components/src/menu/menu-trigger-for.directive.ts +++ b/libs/components/src/menu/menu-trigger-for.directive.ts @@ -192,7 +192,7 @@ export class MenuTriggerForDirective implements OnDestroy { return; } - const escKey = this.overlayRef.keydownEvents().pipe( + const keyEvents = this.overlayRef.keydownEvents().pipe( filter((event: KeyboardEvent) => { const keys = this.menu().ariaRole() === "menu" ? ["Escape", "Tab"] : ["Escape"]; return keys.includes(event.key); @@ -202,8 +202,8 @@ export class MenuTriggerForDirective implements OnDestroy { const detachments = this.overlayRef.detachments(); const closeEvents = isContextMenu - ? merge(detachments, escKey, menuClosed) - : merge(detachments, escKey, this.overlayRef.backdropClick(), menuClosed); + ? merge(detachments, keyEvents, menuClosed) + : merge(detachments, keyEvents, this.overlayRef.backdropClick(), menuClosed); this.closedEventsSub = closeEvents .pipe(takeUntil(this.overlayRef.detachments())) @@ -215,9 +215,9 @@ export class MenuTriggerForDirective implements OnDestroy { event.preventDefault(); } - if (event instanceof KeyboardEvent && (event.key === "Tab" || event.key === "Escape")) { - this.elementRef.nativeElement.focus(); - } + // Move focus to the menu trigger, since any active menu items are about to be destroyed + this.elementRef.nativeElement.focus(); + this.destroyMenu(); }); } diff --git a/libs/components/src/popover/popover-trigger-for.directive.spec.ts b/libs/components/src/popover/popover-trigger-for.directive.spec.ts new file mode 100644 index 00000000000..8a3bdc2cb8c --- /dev/null +++ b/libs/components/src/popover/popover-trigger-for.directive.spec.ts @@ -0,0 +1,446 @@ +import { Overlay, OverlayRef } from "@angular/cdk/overlay"; +import { ChangeDetectionStrategy, Component, NgZone, TemplateRef, viewChild } from "@angular/core"; +import { ComponentFixture, TestBed, fakeAsync, flush, tick } from "@angular/core/testing"; +import { Subject } from "rxjs"; + +import { PopoverTriggerForDirective } from "./popover-trigger-for.directive"; +import { PopoverComponent } from "./popover.component"; + +/** + * Test component to host the directive. + * + * Note: When testing RAF (requestAnimationFrame) behavior in fakeAsync tests: + * - tick() without arguments advances virtual time but does NOT execute RAF callbacks + * - tick(16) advances time by 16ms (typical animation frame duration) and DOES execute RAF callbacks + * - tick(0) flushes microtasks, useful for Angular effects that run synchronously + */ +@Component({ + standalone: true, + template: ` + + + `, + 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/components/kitchen-sink-form.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts index 23c95cafb8a..e54064f0c9d 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts @@ -73,7 +73,6 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module"; A random password