diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1d63af3dd66..c04c9cdeea1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -75,6 +75,7 @@ bitwarden_license/bit-cli/src/admin-console @bitwarden/team-admin-console-dev libs/angular/src/admin-console @bitwarden/team-admin-console-dev libs/common/src/admin-console @bitwarden/team-admin-console-dev libs/admin-console @bitwarden/team-admin-console-dev +libs/auto-confirm @bitwarden/team-admin-console-dev ## Billing team files ## apps/browser/src/billing @bitwarden/team-billing-dev diff --git a/.github/renovate.json5 b/.github/renovate.json5 index b402d01e209..1b6522c94dd 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -298,6 +298,7 @@ "oidc-client-ts", "papaparse", "utf-8-validate", + "verifysign", "zxcvbn", ], description: "Tools owned dependencies", diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index b5859516eaa..7614fdba396 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -193,7 +193,7 @@ jobs: zip -r browser-source.zip browser-source - name: Upload browser source - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{matrix.license_type.archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip path: browser-source.zip @@ -272,7 +272,7 @@ jobs: npm --version - name: Download browser source - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: ${{matrix.license_type.source_archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip @@ -336,7 +336,7 @@ jobs: working-directory: browser-source/apps/browser - name: Upload extension artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ matrix.license_type.artifact_prefix }}${{ matrix.browser.artifact_name }}-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/${{matrix.license_type.archive_name_prefix}}${{ matrix.browser.archive_name }} @@ -349,7 +349,7 @@ jobs: - name: Upload dev extension artifact if: ${{ matrix.browser.archive_name_dev != '' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ matrix.license_type.artifact_prefix }}${{ matrix.browser.artifact_name_dev }}-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/${{matrix.license_type.archive_name_prefix}}${{ matrix.browser.archive_name_dev }} @@ -523,7 +523,7 @@ jobs: ls -la - name: Upload Safari artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{matrix.license_type.archive_name_prefix}}dist-safari-${{ env._BUILD_NUMBER }}.zip path: apps/browser/dist/${{matrix.license_type.archive_name_prefix}}dist-safari.zip diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 704a9810b27..d0abe8e12e7 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -268,7 +268,7 @@ jobs: fi - name: Upload unix zip asset - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip @@ -482,7 +482,7 @@ jobs: } - name: Upload windows zip asset - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bw${{ matrix.license_type.artifact_prefix }}-windows-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-windows-${{ env._PACKAGE_VERSION }}.zip @@ -490,7 +490,7 @@ jobs: - name: Upload Chocolatey asset if: matrix.license_type.build_prefix == 'bit' - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-cli.${{ env._PACKAGE_VERSION }}.nupkg path: apps/cli/dist/chocolatey/bitwarden-cli.${{ env._PACKAGE_VERSION }}.nupkg @@ -503,7 +503,7 @@ jobs: - name: Upload NPM Build Directory asset if: matrix.license_type.build_prefix == 'bit' - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip path: apps/cli/bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip @@ -535,7 +535,7 @@ jobs: echo "BW Package Version: $_PACKAGE_VERSION" - name: Get bw linux cli - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: bw-linux-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/snap @@ -572,7 +572,7 @@ jobs: run: sudo snap remove bw - name: Upload snap asset - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bw_${{ env._PACKAGE_VERSION }}_amd64.snap path: apps/cli/dist/snap/bw_${{ env._PACKAGE_VERSION }}_amd64.snap diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 8e43127770c..6b652149d8d 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -261,42 +261,42 @@ jobs: run: npm run dist:lin - name: Upload tar.gz artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_x64.tar.gz path: apps/desktop/dist/bitwarden_desktop_x64.tar.gz if-no-files-found: error - name: Upload .deb artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb if-no-files-found: error - name: Upload .rpm artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm if-no-files-found: error - name: Upload .snap artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap if-no-files-found: error - name: Upload .AppImage artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ needs.setup.outputs.release_channel }}-linux.yml path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-linux.yml @@ -309,7 +309,7 @@ jobs: sudo npm run pack:lin:flatpak - name: Upload flatpak artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: com.bitwarden.desktop.flatpak path: apps/desktop/dist/com.bitwarden.desktop.flatpak @@ -437,14 +437,14 @@ jobs: run: npm run dist:lin:arm64 - name: Upload .snap artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_arm64.snap path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_arm64.snap if-no-files-found: error - name: Upload tar.gz artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_arm64.tar.gz path: apps/desktop/dist/bitwarden_desktop_arm64.tar.gz @@ -457,7 +457,7 @@ jobs: sudo npm run pack:lin:flatpak - name: Upload flatpak artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: com.bitwarden.desktop-arm64.flatpak path: apps/desktop/dist/com.bitwarden.desktop.flatpak @@ -630,7 +630,7 @@ jobs: -NewName bitwarden-$env:_PACKAGE_VERSION-arm64.nsis.7z - name: Upload portable exe artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe @@ -638,7 +638,7 @@ jobs: - name: Upload installer exe artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe @@ -646,7 +646,7 @@ jobs: - name: Upload appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx @@ -654,7 +654,7 @@ jobs: - name: Upload store appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx @@ -662,7 +662,7 @@ jobs: - name: Upload NSIS ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z @@ -670,7 +670,7 @@ jobs: - name: Upload appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx @@ -678,7 +678,7 @@ jobs: - name: Upload store appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx @@ -686,7 +686,7 @@ jobs: - name: Upload NSIS x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z @@ -694,7 +694,7 @@ jobs: - name: Upload appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx @@ -702,7 +702,7 @@ jobs: - name: Upload store appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx @@ -710,7 +710,7 @@ jobs: - name: Upload NSIS ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z @@ -718,7 +718,7 @@ jobs: - name: Upload nupkg artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden.${{ env._PACKAGE_VERSION }}.nupkg path: apps/desktop/dist/chocolatey/bitwarden.${{ env._PACKAGE_VERSION }}.nupkg @@ -726,7 +726,7 @@ jobs: - name: Upload auto-update artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ needs.setup.outputs.release_channel }}.yml path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml @@ -883,7 +883,7 @@ jobs: -NewName latest-beta.yml - name: Upload portable exe artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe @@ -891,7 +891,7 @@ jobs: - name: Upload installer exe artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/nsis-web/Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe @@ -899,7 +899,7 @@ jobs: - name: Upload appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx @@ -907,7 +907,7 @@ jobs: - name: Upload store appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx @@ -915,7 +915,7 @@ jobs: - name: Upload NSIS ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z @@ -923,7 +923,7 @@ jobs: - name: Upload appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx @@ -931,7 +931,7 @@ jobs: - name: Upload store appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx @@ -939,7 +939,7 @@ jobs: - name: Upload NSIS x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z @@ -947,7 +947,7 @@ jobs: - name: Upload appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx @@ -955,7 +955,7 @@ jobs: - name: Upload store appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx @@ -963,7 +963,7 @@ jobs: - name: Upload NSIS ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z @@ -971,7 +971,7 @@ jobs: - name: Upload auto-update artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: latest-beta.yml path: apps/desktop/dist/nsis-web/latest-beta.yml @@ -1429,7 +1429,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts @@ -1462,28 +1462,28 @@ jobs: run: npm run pack:mac - name: Upload .zip artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip if-no-files-found: error - name: Upload .dmg artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg if-no-files-found: error - name: Upload .dmg blockmap artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ needs.setup.outputs.release_channel }}-mac.yml path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-mac.yml @@ -1712,7 +1712,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts @@ -1755,14 +1755,14 @@ jobs: $buildInfo | ConvertTo-Json | Set-Content -Path dist/macos-build-number.json - name: Upload MacOS App Store build number artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: macos-build-number.json path: apps/desktop/dist/macos-build-number.json if-no-files-found: error - name: Upload .pkg artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg @@ -1884,6 +1884,321 @@ jobs: upload_sources: true upload_translations: false + validate-linux-x64-deb: + name: Validate Linux x64 .deb + runs-on: ubuntu-22.04 + needs: + - setup + - linux + env: + _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + steps: + - name: Check out repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 1 + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Download deb artifact + uses: bitwarden/gh-actions/download-artifacts@main + with: + path: apps/desktop/artifacts/linux/deb + artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb + + - name: Install deps + run: | + sudo apt-get update + sudo apt-get install -y libasound2 xvfb + + - name: Install .deb + working-directory: apps/desktop/artifacts/linux/deb + run: sudo apt-get install -y ./Bitwarden-${_PACKAGE_VERSION}-amd64.deb + + - name: Run .deb + run: | + xvfb-run -a bitwarden & + sleep 30 + if pgrep bitwarden > /dev/null; then + pkill -9 bitwarden + echo "Bitwarden is running." + else + echo "Bitwarden is not running." + exit 1 + fi + + validate-linux-x64-appimage: + name: Validate Linux x64 appimage + runs-on: ubuntu-22.04 + needs: + - setup + - linux + env: + _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + steps: + - name: Check out repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 1 + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + - name: Download appimage artifact + uses: bitwarden/gh-actions/download-artifacts@main + with: + path: apps/desktop/artifacts/linux/appimage + artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage + + - name: Install deps + run: | + sudo apt-get update + sudo apt-get install -y libasound2 libfuse2 xvfb + + - name: Run AppImage + working-directory: apps/desktop/artifacts/linux/appimage + run: | + chmod a+x ./Bitwarden-${_PACKAGE_VERSION}-x86_64.AppImage + xvfb-run -a ./Bitwarden-${_PACKAGE_VERSION}-x86_64.AppImage --no-sandbox & + sleep 30 + if pgrep bitwarden > /dev/null; then + pkill -9 bitwarden + echo "Bitwarden is running." + else + echo "Bitwarden is not running." + exit 1 + fi + + validate-linux-wayland: + name: Validate Linux Wayland + runs-on: ubuntu-22.04 + needs: + - setup + - linux + env: + _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + steps: + - name: Check out repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 1 + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + - name: Download appimage artifact + uses: bitwarden/gh-actions/download-artifacts@main + with: + path: apps/desktop/artifacts/linux/appimage + artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage + + - name: Install deps + run: | + sudo apt-get update + sudo apt-get install -y libasound2 libfuse2 xvfb + sudo apt-get install -y weston libwayland-client0 libwayland-server0 libwayland-dev + + - name: Run headless Wayland compositor + run: | + # Start Weston in a virtual terminal in headless mode + weston --headless --socket=wayland-0 & + # Let the compositor start + sleep 5 + + - name: Run AppImage + working-directory: apps/desktop/artifacts/linux/appimage + env: + WAYLAND_DISPLAY: wayland-0 + run: | + chmod a+x ./Bitwarden-${_PACKAGE_VERSION}-x86_64.AppImage + xvfb-run -a ./Bitwarden-${_PACKAGE_VERSION}-x86_64.AppImage --no-sandbox & + sleep 30 + if pgrep bitwarden > /dev/null; then + pkill -9 bitwarden + echo "Bitwarden is running." + else + echo "Bitwarden is not running." + exit 1 + fi + + validate-linux-flatpak: + name: Validate Linux ${{ matrix.os }} Flatpak + runs-on: ${{ matrix.os || 'ubuntu-22.04' }} + strategy: + matrix: + os: + - ubuntu-22.04 + - ubuntu-22.04-arm + needs: + - setup + - linux + - linux-arm64 + steps: + - name: Check out repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 1 + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + - name: Download flatpak artifact + uses: bitwarden/gh-actions/download-artifacts@main + with: + path: apps/desktop/artifacts/linux/flatpak/ + artifacts: com.bitwarden.${{ matrix.os == 'ubuntu-22.04' && 'desktop' || 'desktop-arm64' }}.flatpak + + - name: Install deps + run: | + sudo apt-get update + sudo apt-get install -y libasound2 flatpak xvfb dbus-x11 + flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo + flatpak install -y --user flathub + + - name: Install flatpak + working-directory: apps/desktop/artifacts/linux/flatpak + run: flatpak install -y --user --bundle com.bitwarden.desktop.flatpak + + - name: Run Flatpak + run: | + export $(dbus-launch) + xvfb-run -a flatpak run com.bitwarden.desktop & + sleep 30 + if pgrep bitwarden > /dev/null; then + pkill -9 bitwarden + echo "Bitwarden is running." + else + echo "Bitwarden is not running." + exit 1 + fi + + validate-linux-snap: + name: Validate Linux ${{ matrix.os }} Snap + runs-on: ${{ matrix.os || 'ubuntu-22.04' }} + strategy: + matrix: + os: + - ubuntu-22.04 + - ubuntu-22.04-arm + needs: + - setup + - linux + - linux-arm64 + env: + _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + _CPU_ARCH: ${{ matrix.os == 'ubuntu-22.04' && 'amd64' || 'arm64' }} + steps: + - name: Check out repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 1 + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + - name: Download snap artifact + uses: bitwarden/gh-actions/download-artifacts@main + with: + path: apps/desktop/artifacts/linux/snap + artifacts: bitwarden_${{ env._PACKAGE_VERSION }}_${{ env._CPU_ARCH }}.snap + + - name: Install deps + run: | + sudo apt-get update + sudo apt-get install -y libasound2 snapd xvfb + + - name: Install snap + working-directory: apps/desktop/artifacts/linux/snap + run: | + sudo snap install --dangerous ./bitwarden_${_PACKAGE_VERSION}_${_CPU_ARCH}.snap + + - name: Run Snap + run: | + xvfb-run -a snap run bitwarden & + sleep 30 + if pgrep bitwarden > /dev/null; then + pkill -9 bitwarden + echo "Bitwarden is running." + else + echo "Bitwarden is not running." + exit 1 + fi + + validate-macos-dmg: + name: Validate MacOS dmg + runs-on: macos-15 + needs: + - setup + - macos-package-github + env: + _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + steps: + - name: Check out repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 1 + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + - name: Download dmg artifact + uses: bitwarden/gh-actions/download-artifacts@main + with: + path: apps/desktop/artifacts/macos/dmg + artifacts: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg + + - name: Install dmg + working-directory: apps/desktop/artifacts/macos/dmg + run: | + # mount + hdiutil attach Bitwarden-${_PACKAGE_VERSION}-universal.dmg + # install + cp -r /Volumes/Bitwarden\ ${_PACKAGE_VERSION}-universal/Bitwarden.app /Applications/ + # unmount + hdiutil detach /Volumes/Bitwarden\ ${_PACKAGE_VERSION}-universal + + - name: Run dmg + run: | + open /Applications/Bitwarden.app/Contents/MacOS/Bitwarden & + sleep 30 + if pgrep Bitwarden > /dev/null; then + pkill -9 Bitwarden + echo "Bitwarden is running." + else + echo "Bitwarden is not running." + exit 1 + fi + + validate-windows-portable: + name: Validate Windows portable + runs-on: windows-2022 + needs: + - setup + - windows + env: + _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + steps: + - name: Check out repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + fetch-depth: 1 + ref: ${{ github.event.workflow_run.head_sha }} + persist-credentials: false + + - name: Download portable artifact + uses: bitwarden/gh-actions/download-artifacts@main + with: + path: apps/desktop/artifacts/windows/portable + artifacts: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe + + - name: Run Portable exe + working-directory: apps/desktop/artifacts/windows/portable + run: | + "./Bitwarden-Portable-$_PACKAGE_VERSION.exe" --no-sandbox & + sleep 30 + if tasklist | grep Bitwarden ; then + taskkill //F //IM "Bitwarden.exe" + echo "Bitwarden is running." + else + echo "Bitwarden is not running." + exit 1 + fi + check-failures: name: Check for failures if: always() @@ -1898,6 +2213,13 @@ jobs: - macos-package-github - macos-package-mas - crowdin-push + - validate-linux-x64-deb + - validate-linux-x64-appimage + - validate-linux-flatpak + - validate-linux-snap + - validate-linux-wayland + - validate-macos-dmg + - validate-windows-portable permissions: contents: read id-token: write diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 7d302fb453b..24a8df084a2 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -307,7 +307,7 @@ jobs: zip -r web-$_VERSION-${{ matrix.artifact_name }}.zip build - name: Upload ${{ matrix.artifact_name }} artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: web-${{ env._VERSION }}-${{ matrix.artifact_name }}.zip path: apps/web/web-${{ env._VERSION }}-${{ matrix.artifact_name }}.zip diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3aeb75dcbf6..ea6894dab8e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -100,13 +100,13 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # stable + uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable with: toolchain: stable components: rustfmt, clippy - name: Install Rust nightly - uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # stable + uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable with: toolchain: nightly components: rustfmt @@ -115,7 +115,7 @@ jobs: run: rustup --version - name: Cache cargo registry - uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7 + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 - name: Run cargo fmt working-directory: ./apps/desktop/desktop_native diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 1e23c31b033..3a7431c07f0 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -36,7 +36,7 @@ jobs: run: npm ci - name: Set Nx SHAs for affected detection - uses: nrwl/nx-set-shas@826660b82addbef3abff5fa871492ebad618c9e1 # v4.3.3 + uses: nrwl/nx-set-shas@3e9ad7370203c1e93d109be57f3b72eb0eb511b1 # v4.4.0 - name: Run Nx affected tasks continue-on-error: true diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index b2edf0171db..79f3335313e 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -71,6 +71,8 @@ jobs: version_web: ${{ steps.set-final-version-output.outputs.version_web }} permissions: id-token: write + contents: write + pull-requests: write steps: - name: Validate version input format @@ -93,6 +95,13 @@ jobs: keyvault: gh-org-bitwarden secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + - name: Retrieve GPG secrets + id: retrieve-gpg-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: "bitwarden-ci" + secrets: "github-gpg-private-key, github-gpg-private-key-passphrase" + - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main @@ -102,7 +111,8 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - permission-contents: write # for committing and pushing to current branch + permission-contents: write # for creating, committing to, and pushing new branches + permission-pull-requests: write # for generating pull requests - name: Check out branch uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 @@ -113,8 +123,20 @@ jobs: - name: Configure Git run: | - git config --local user.email "actions@github.com" - git config --local user.name "Github Actions" + git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com" + git config --local user.name "bitwarden-devops-bot" + + - name: Setup GPG signing + env: + GPG_PRIVATE_KEY: ${{ steps.retrieve-gpg-secrets.outputs.github-gpg-private-key }} + GPG_PASSPHRASE: ${{ steps.retrieve-gpg-secrets.outputs.github-gpg-private-key-passphrase }} + run: | + echo "$GPG_PRIVATE_KEY" | gpg --import --batch + GPG_KEY_ID=$(gpg --list-secret-keys --keyid-format=long | grep -o "rsa[0-9]\+/[A-F0-9]\+" | head -n1 | cut -d'/' -f2) + git config --local user.signingkey "$GPG_KEY_ID" + git config --local commit.gpgsign true + export GPG_TTY=$(tty) + echo "test" | gpg --clearsign --pinentry-mode=loopback --passphrase "$GPG_PASSPHRASE" > /dev/null 2>&1 ######################## # VERSION BUMP SECTION # @@ -426,13 +448,53 @@ jobs: echo "No changes to commit!"; fi - - name: Commit files + - name: Create version bump branch if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - run: git commit -m "Bumped client version(s)" -a + run: | + BRANCH_NAME="version-bump-$(date +%s)" + git checkout -b "$BRANCH_NAME" + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV - - name: Push changes + - name: Commit version bumps with GPG signature if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - run: git push + run: | + git commit -m "Bumped client version(s)" -a + + - name: Push version bump branch + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} + run: | + git push --set-upstream origin "$BRANCH_NAME" + + - name: Create Pull Request for version bump + if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + VERSION_BROWSER: ${{ steps.set-final-version-output.outputs.version_browser }} + VERSION_CLI: ${{ steps.set-final-version-output.outputs.version_cli }} + VERSION_DESKTOP: ${{ steps.set-final-version-output.outputs.version_desktop }} + VERSION_WEB: ${{ steps.set-final-version-output.outputs.version_web }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const versions = []; + if (process.env.VERSION_BROWSER) versions.push(`- Browser: ${process.env.VERSION_BROWSER}`); + if (process.env.VERSION_CLI) versions.push(`- CLI: ${process.env.VERSION_CLI}`); + if (process.env.VERSION_DESKTOP) versions.push(`- Desktop: ${process.env.VERSION_DESKTOP}`); + if (process.env.VERSION_WEB) versions.push(`- Web: ${process.env.VERSION_WEB}`); + + const body = versions.length > 0 + ? `Automated version bump:\n\n${versions.join('\n')}` + : 'Automated version bump'; + + const { data: pr } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'Bumped client version(s)', + body: body, + head: process.env.BRANCH_NAME, + base: context.ref.replace('refs/heads/', '') + }); + console.log(`Created PR #${pr.number}: ${pr.html_url}`); cut_branch: name: Cut branch diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8f062ea345..cf7251b259a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,7 +62,7 @@ jobs: run: npm test -- --coverage --maxWorkers=3 - name: Report test results - uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 # v2.1.1 + uses: dorny/test-reporter@7b7927aa7da8b82e81e755810cb51f39941a2cc7 # v2.2.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results @@ -74,7 +74,7 @@ jobs: uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 - name: Upload test coverage - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: jest-coverage path: ./coverage/lcov.info @@ -142,7 +142,7 @@ jobs: persist-credentials: false - name: Install rust - uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable + uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable with: toolchain: stable components: llvm-tools @@ -160,7 +160,7 @@ jobs: run: cargo llvm-cov --all-features --lcov --output-path lcov.info --workspace --no-cfg-coverage - name: Upload test coverage - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: rust-coverage path: ./apps/desktop/desktop_native/lcov.info @@ -178,13 +178,13 @@ jobs: persist-credentials: false - name: Download jest coverage - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: jest-coverage path: ./ - name: Download rust coverage - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: rust-coverage path: ./apps/desktop/desktop_native diff --git a/.storybook/main.ts b/.storybook/main.ts index e1f3561a1b7..353d959a6b9 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -12,6 +12,8 @@ const config: StorybookConfig = { "../libs/dirt/card/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/pricing/src/**/*.mdx", "../libs/pricing/src/**/*.stories.@(js|jsx|ts|tsx)", + "../libs/subscription/src/**/*.mdx", + "../libs/subscription/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/tools/send/send-ui/src/**/*.mdx", "../libs/tools/send/send-ui/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/vault/src/**/*.mdx", diff --git a/apps/browser/spec/mock-port.spec-util.ts b/apps/browser/spec/mock-port.spec-util.ts index 39239ba8817..427a0b3aa9c 100644 --- a/apps/browser/spec/mock-port.spec-util.ts +++ b/apps/browser/spec/mock-port.spec-util.ts @@ -12,7 +12,10 @@ export function mockPorts() { (chrome.runtime.connect as jest.Mock).mockImplementation((portInfo) => { const port = mockDeep(); port.name = portInfo.name; - port.sender = { url: chrome.runtime.getURL("") }; + port.sender = { + url: chrome.runtime.getURL(""), + origin: chrome.runtime.getURL(""), + }; // convert to internal port delete (port as any).tab; diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 25cc1e31425..debe3f82c1b 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "تعديل" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index d7257bab478..547ebee9500 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Arxivi istifadə etmək üçün premium üzvlük tələb olunur." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Düzəliş et" }, @@ -1251,7 +1254,7 @@ "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { - "message": "Parolunuzu dəyişdirdikdən sonra yeni parolunuzla giriş etməli olacaqsınız. Digər cihazlardakı aktiv sessiyalar bir saat ərzində çıxış sonlandırılacaq." + "message": "Parolunuzu dəyişdirdikdən sonra yeni parolunuzla giriş etməli olacaqsınız. Digər cihazlardakı aktiv sessiyalar bir saat ərzində sonlandırılacaq." }, "accountRecoveryUpdateMasterPasswordSubtitle": { "message": "Hesabın geri qaytarılması prosesini tamamlamaq üçün ana parolunuzu dəyişdirin." @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Bu giriş risk altındadır və bir veb sayt əskikdir. Daha güclü təhlükəsizlik üçün bir veb sayt əlavə edin və parolu dəyişdirin." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Əskik veb sayt" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Bunu niyə görürəm?" + }, + "resizeSideNavigation": { + "message": "Yan naviqasiyanı yeni. ölçüləndir" } } diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index fd4dbb780da..5f765c0045d 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Рэдагаваць" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 1dc20d6ab65..c94e8ff2fdc 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "За да се възползвате от архивирането, трябва да ползвате платен абонамент." }, + "itemRestored": { + "message": "Записът бе възстановен" + }, "edit": { "message": "Редактиране" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Този елемент за вписване е в риск и в него липсва уеб сайт. Добавете уеб сайт и сменете паролата, за по-добра сигурност." }, + "vulnerablePassword": { + "message": "Уязвима парола." + }, + "changeNow": { + "message": "Промяна сега" + }, "missingWebsite": { "message": "Липсващ уеб сайт" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Защо виждам това?" + }, + "resizeSideNavigation": { + "message": "Преоразмеряване на страничната навигация" } } diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index baf98bcb50a..106b61dd9f8 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "সম্পাদনা" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index df473b0192d..ac17bac7097 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 4bbfb9a418e..96944f45b5b 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edita" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index e40edbb8091..1d00f81a62c 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Pro použití funkce Archiv je potřebné prémiové členství." }, + "itemRestored": { + "message": "Položka byla obnovena" + }, "edit": { "message": "Upravit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Tyto přihlašovací údaje jsou ohrožené a chybí jim webová stránka. Přidejte webovou stránku a změňte heslo pro větší bezpečnost." }, + "vulnerablePassword": { + "message": "Zranitelné heslo." + }, + "changeNow": { + "message": "Změnit nyní" + }, "missingWebsite": { "message": "Chybějící webová stránka" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Proč se mi toto zobrazuje?" + }, + "resizeSideNavigation": { + "message": "Změnit velikost boční navigace" } } diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 896eee5af4f..c6a380da1a6 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Golygu" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index ad6e5b0e90d..4871e7e7100 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Redigér" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 5ba6dd419a1..d996c1d0835 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Für die Nutzung des Archivs ist eine Premium-Mitgliedschaft erforderlich." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Bearbeiten" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Diese Zugangsdaten sind gefährdet und es fehlt eine Website. Füge eine Website hinzu und ändere das Passwort für mehr Sicherheit." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Fehlende Website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Warum wird mir das angezeigt?" + }, + "resizeSideNavigation": { + "message": "Größe der Seitennavigation ändern" } } diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 29593380dd9..e05cc5f4d6a 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Επεξεργασία" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 95d3f662994..29b39863bc6 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4811,24 @@ "adminConsole": { "message": "Admin Console" }, + "admin" :{ + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Automatic user confirmation" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout":{ + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "This could impact your organization’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5685,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6018,7 +6045,7 @@ "placeholders": { "organization": { "content": "$1", - "example": "My Org Name" + "example": "My Org Name" } } }, @@ -6027,7 +6054,7 @@ "placeholders": { "organization": { "content": "$1", - "example": "My Org Name" + "example": "My Org Name" } } }, diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index f64bf387a5e..1e22c5ffa34 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 34f2f0b1105..cbb2851f872 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 276d0dbedb2..1160899a4d3 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Editar" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 3efb771966d..69fa7ef8bfc 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Muuda" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index d8f8c5230c6..d5aeb2ce295 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Editatu" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index c264b292761..510b71de2ee 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "ویرایش" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 38003bb31b2..011c5fb9026 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Muokkaa" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 38702b940c4..260449921bd 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "I-edit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index afc71011900..0cd6abfee60 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Une adhésion premium est requise pour utiliser Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Modifier" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Cet identifiant est à risques et manque un site web. Ajoutez un site web et changez le mot de passe pour une meilleure sécurité." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Site Web manquant" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 7bfe70bded5..642659da268 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Editar" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index fd4e010ce60..b59499f1d6d 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "ערוך" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "כניסה זו נמצאת בסיכון וחסר בה אתר אינטרנט. הוסף אתר אינטרנט ושנה את הסיסמה לאבטחה חזקה יותר." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "לא נמצא אתר אינטרנט" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index dc91aa89197..637c1943174 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "संपादन करें" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index c472bfa50cd..bef42a97294 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Uredi" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Ova prijava je ugrožena i nedostaje joj web-stranica. Dodaj web-stranicu i promijeni lozinku za veću sigurnost." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Nedostaje web-stranica" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 438c574bba9..915f2241efd 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Az Archívum használatához prémium tagság szükséges." }, + "itemRestored": { + "message": "Az elem visszaállításra került." + }, "edit": { "message": "Szerkesztés" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Ez a bejelentkezés veszélyben van és hiányzik egy webhely. Adjunk hozzá egy webhelyet és módosítsuk a jelszót az erősebb biztonság érdekében." }, + "vulnerablePassword": { + "message": "A jelszó sérülékeny." + }, + "changeNow": { + "message": "Módosítás most" + }, "missingWebsite": { "message": "Hiányzó webhely" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Miért látható ez?" + }, + "resizeSideNavigation": { + "message": "Oldalnavigáció átméretezés" } } diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 4d636a5a79d..643b72125a2 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 2615640df0a..a74b4aa4757 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Per utilizzare Archivio è necessario un abbonamento premium." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Modifica" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Questo login è a rischio e non contiene un sito web. Aggiungi un sito web e cambia la password per maggiore sicurezza." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Sito web mancante" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Perché vedo questo avviso?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 91c006fccca..8233240d728 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "アーカイブを使用するにはプレミアムメンバーシップが必要です。" }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "編集" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "ウェブサイトがありません" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 85c2b04f469..7b79332d906 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "ჩასწორება" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 04386b72930..ea4f2b08a85 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index c29ecc2d04f..6b5c8251bf1 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "ಎಡಿಟ್" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index ccf3d96d366..8841a307e5a 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "편집" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index a1e3e287892..bb95441b30a 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Keisti" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 95e5d2399e6..9ef89e918b1 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Ir nepieciešama Premium dalība, lai izmantotu arhīvu." }, + "itemRestored": { + "message": "Vienums tika atjaunots" + }, "edit": { "message": "Labot" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Šis pieteikšanās vienums ir pakļauts riskam, un tam nav norādīta tīmekļvietne. Lielākai drošībai jāpievieno tīmekļvietne un jānomaina parole." }, + "vulnerablePassword": { + "message": "Ievainojama parole." + }, + "changeNow": { + "message": "Mainīt tagad" + }, "missingWebsite": { "message": "Nav norādīta tīmekļvietne" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Mainīt sānu pārvietošanās joslas izmēru" } } diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 3257c4c745c..bb025788e18 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "തിരുത്തുക" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 38487d00f9d..8440297105c 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 04386b72930..ea4f2b08a85 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 9056ce4ad8e..fcf1a3f14d9 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Rediger" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 04386b72930..ea4f2b08a85 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index ea040cb057b..ac465690dcd 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Je hebt een Premium-abonnement nodig om te kunnen archiveren." }, + "itemRestored": { + "message": "Item is hersteld" + }, "edit": { "message": "Bewerken" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Deze login is in gevaar en mist een website. Voeg een website toe en verander het wachtwoord voor een sterkere beveiliging." }, + "vulnerablePassword": { + "message": "Kwetsbaar wachtwoord." + }, + "changeNow": { + "message": "Nu wijzigen" + }, "missingWebsite": { "message": "Ontbrekende website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Waarom zie ik dit?" + }, + "resizeSideNavigation": { + "message": "Formaat zijnavigatie wijzigen" } } diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 04386b72930..ea4f2b08a85 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 04386b72930..ea4f2b08a85 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 67e7a2a2a00..5162829669d 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edytuj" }, @@ -1047,10 +1050,10 @@ "message": "Element został zapisany" }, "savedWebsite": { - "message": "Zapisana witryna" + "message": "Zapisana strona internetowa" }, "savedWebsites": { - "message": "Zapisane witryny ($COUNT$)", + "message": "Zapisane strony internetowe ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -1429,7 +1432,7 @@ "message": "Confirm your identity to continue" }, "enterYourMasterPassword": { - "message": "Enter your master password" + "message": "Wpisz hasło główne" }, "updateSettings": { "message": "Zaktualizuj ustawienia" @@ -1771,13 +1774,13 @@ "message": "W jaki sposób Bitwarden chroni Twoje dane przed phishingiem?" }, "currentWebsite": { - "message": "Aktualna witryna" + "message": "Obecna strona internetowa" }, "autofillAndAddWebsite": { - "message": "Wypełnij automatycznie i dodaj tę witrynę" + "message": "Uzupełnij i dodaj stronę internetową" }, "autofillWithoutAdding": { - "message": "Automatyczne uzupełnianie bez dodawania" + "message": "Uzupełnij bez dodawania" }, "doNotAutofill": { "message": "Nie wypełniaj automatycznie" @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Dane logowania są zagrożone i nie zawierają strony internetowej. Dodaj stronę internetową i zmień hasło." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Brak strony internetowej" }, @@ -5894,7 +5903,7 @@ "message": "Sejf załadowany" }, "settingDisabledByPolicy": { - "message": "To ustawienie jest wyłączone zgodnie z zasadami polityki Twojej organizacji.", + "message": "Ustawienie jest wyłączone przez Twoją organizację.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." }, "zipPostalCodeLabel": { @@ -5907,10 +5916,10 @@ "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, "continueWithLogIn": { - "message": "Continue with log in" + "message": "Kontynuuj logowanie" }, "doNotContinue": { - "message": "Do not continue" + "message": "Nie kontynuuj" }, "domain": { "message": "Domena" @@ -5943,7 +5952,7 @@ "message": "To continue with log in, verify the organization and domain." }, "sessionTimeoutSettingsAction": { - "message": "Akcja przekroczenia limitu czasu" + "message": "Sposób blokady" }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." @@ -6002,7 +6011,7 @@ "message": "Contact your admin to regain access." }, "leaveConfirmationDialogConfirmButton": { - "message": "Leave $ORGANIZATION$", + "message": "Opuść organizację $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -6011,10 +6020,10 @@ } }, "howToManageMyVault": { - "message": "How do I manage my vault?" + "message": "Jak zarządzać sejfami?" }, "transferItemsToOrganizationTitle": { - "message": "Transfer items to $ORGANIZATION$", + "message": "Przenieś elementy do organizacji $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Zmień rozmiar nawigacji bocznej" } } diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index c7ecfe3f81d..2a35a6f0c64 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -203,13 +203,13 @@ "message": "Preenchimento automático" }, "autoFillLogin": { - "message": "Preencher credencial automaticamente" + "message": "Preencher credencial" }, "autoFillCard": { - "message": "Preencher cartão automaticamente" + "message": "Preencher cartão" }, "autoFillIdentity": { - "message": "Preencher identidade automaticamente" + "message": "Preencher identidade" }, "fillVerificationCode": { "message": "Preencher código de verificação" @@ -249,7 +249,7 @@ "message": "Conecte-se ao seu cofre" }, "autoFillInfo": { - "message": "Não há credenciais disponíveis para preencher automaticamente na aba atual do navegador." + "message": "Não há credenciais disponíveis para preencher na aba atual do navegador." }, "addLogin": { "message": "Adicionar uma credencial" @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Um plano Premium é necessário para usar o arquivamento." }, + "itemRestored": { + "message": "O item foi restaurado" + }, "edit": { "message": "Editar" }, @@ -895,7 +898,7 @@ } }, "autofillError": { - "message": "Não é possível preencher automaticamente o item selecionado nesta página. Em vez disso, copie e cole a informação." + "message": "Não é possível preencher o item selecionado nesta página. Em vez disso, copie e cole a informação." }, "totpCaptureError": { "message": "Não é possível ler o código QR da página atual" @@ -1120,7 +1123,7 @@ "message": "Listar as identidades na página da aba para facilitar o preenchimento automático." }, "clickToAutofillOnVault": { - "message": "Clique em itens na tela do Cofre para preencher automaticamente" + "message": "Clique em itens na tela do Cofre para preencher" }, "clickToAutofill": { "message": "Clicar itens na sugestão para preenchê-lo" @@ -1585,7 +1588,7 @@ "message": "Copiar TOTP automaticamente" }, "disableAutoTotpCopyDesc": { - "message": "Se uma credencial tiver uma chave de autenticador, copie o código de verificação TOTP quando for preenchê-la automaticamente." + "message": "Se uma credencial tiver uma chave de autenticador, copie o código de verificação TOTP quando for preenchê-la." }, "enableAutoBiometricsPrompt": { "message": "Pedir biometria ao abrir" @@ -1774,13 +1777,13 @@ "message": "Site atual" }, "autofillAndAddWebsite": { - "message": "Preencher automaticamente e adicionar este site" + "message": "Preencher e adicionar este site" }, "autofillWithoutAdding": { - "message": "Preencher automaticamente sem adicionar" + "message": "Preencher sem adicionar" }, "doNotAutofill": { - "message": "Não preencher automaticamente" + "message": "Não preencher" }, "showInlineMenuIdentitiesLabel": { "message": "Exibir identidades como sugestões" @@ -1813,10 +1816,10 @@ "description": "Overlay appearance select option for showing the field on click of the overlay icon" }, "enableAutoFillOnPageLoadSectionTitle": { - "message": "Preencher automaticamente ao carregar a página" + "message": "Preenchimento no carregamento da página" }, "enableAutoFillOnPageLoad": { - "message": "Preencher automaticamente ao carregar a página" + "message": "Preencher ao carregar a página" }, "enableAutoFillOnPageLoadDesc": { "message": "Se um formulário de credencial for detectado, preencha automaticamente quando a página carregar." @@ -1840,10 +1843,10 @@ "message": "Usar configuração padrão" }, "autoFillOnPageLoadYes": { - "message": "Preencher automaticamente ao carregar a página" + "message": "Preencher ao carregar a página" }, "autoFillOnPageLoadNo": { - "message": "Não preencher automaticamente ao carregar a página" + "message": "Não preencher ao carregar a página" }, "commandOpenPopup": { "message": "Abrir pop-up do cofre" @@ -1852,13 +1855,13 @@ "message": "Abrir cofre na barra lateral" }, "commandAutofillLoginDesc": { - "message": "Preencher automaticamente a última credencial utilizada para o site atual" + "message": "Preencher com a última credencial utilizada para o site atual" }, "commandAutofillCardDesc": { - "message": "Preencher automaticamente o último cartão utilizado para o site atual" + "message": "Preencher com o último cartão utilizado para o site atual" }, "commandAutofillIdentityDesc": { - "message": "Preencher automaticamente a última identidade usada para o site atual" + "message": "Preencher com a última identidade usada para o site atual" }, "commandGeneratePasswordDesc": { "message": "Gere e copie uma nova senha aleatória para a área de transferência" @@ -2468,7 +2471,7 @@ "message": "Confirmação da ação do limite de tempo" }, "autoFillAndSave": { - "message": "Preencher automaticamente e salvar" + "message": "Preencher e salvar" }, "fillAndSave": { "message": "Preencher e salvar" @@ -2486,7 +2489,7 @@ "message": "Você ainda deseja preencher esta credencial?" }, "autofillIframeWarning": { - "message": "O formulário está hospedado em um domínio diferente do URI da sua credencial salva. Escolha OK para preencher automaticamente mesmo assim, ou Cancelar para parar." + "message": "O formulário está hospedado em um domínio diferente do URI da sua credencial salva. Escolha OK para preencher mesmo assim, ou Cancelar para parar." }, "autofillIframeWarningTip": { "message": "Para evitar este aviso no futuro, salve este URI, $HOSTNAME$, no seu item de credencial no Bitwarden para este site.", @@ -2812,7 +2815,7 @@ "message": "Altere senhas em risco mais rápido" }, "changeAtRiskPasswordsFasterDesc": { - "message": "Atualize suas configurações para poder preencher senhas automaticamente ou gerá-las automaticamente" + "message": "Atualize suas configurações para poder preencher ou gerar novas senhas" }, "reviewAtRiskLogins": { "message": "Revisar credenciais em risco" @@ -4121,7 +4124,7 @@ "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, "cannotAutofill": { - "message": "Não é possível preencher automaticamente" + "message": "Não é possível preencher" }, "cannotAutofillExactMatch": { "message": "A correspondência padrão está configurada como 'Correspondência exata'. O site atual não corresponde exatamente aos detalhes salvos de credencial para este item." @@ -4677,7 +4680,7 @@ "message": "Itens sugeridos" }, "autofillSuggestionsTip": { - "message": "Salve um item de credencial para este site para preencher automaticamente" + "message": "Salve um item de credencial para este site para preencher" }, "yourVaultIsEmpty": { "message": "Seu cofre está vazio" @@ -4753,7 +4756,7 @@ } }, "autofillTitle": { - "message": "Preencher automaticamente - $ITEMNAME$", + "message": "Preencher - $ITEMNAME$", "description": "Title for a button that autofills a login item.", "placeholders": { "itemname": { @@ -4763,7 +4766,7 @@ } }, "autofillTitleWithField": { - "message": "Preencher automaticamente - $ITEMNAME$ - $FIELD$", + "message": "Preencher - $ITEMNAME$ - $FIELD$", "description": "Title for a button that autofills a login item.", "placeholders": { "itemname": { @@ -4853,7 +4856,7 @@ } }, "new": { - "message": "Novo" + "message": "Criar" }, "removeItem": { "message": "Remover $NAME$", @@ -5081,7 +5084,7 @@ } }, "autoFillOnPageLoad": { - "message": "Preencher automaticamente ao carregar a página?" + "message": "Preencher ao carregar a página?" }, "cardExpiredTitle": { "message": "Cartão vencido" @@ -5165,7 +5168,7 @@ "message": "Use campos ocultos para dados confidenciais como uma senha" }, "checkBoxHelpText": { - "message": "Use caixas de seleção se gostaria de preencher automaticamente a caixa de seleção de um formulário, como um lembrar e-mail" + "message": "Use caixas de seleção se gostaria de preencher a caixa de seleção de um formulário, como um lembrar e-mail" }, "linkedHelpText": { "message": "Use um campo vinculado quando estiver enfrentando problemas com o preenchimento automático com um site específico." @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Esta credencial está em risco e está sem um site. Adicione um site e altere a senha para segurança melhor." }, + "vulnerablePassword": { + "message": "Senha vulnerável." + }, + "changeNow": { + "message": "Alterar agora" + }, "missingWebsite": { "message": "Site ausente" }, @@ -5748,7 +5757,7 @@ } }, "hasItemsVaultNudgeBodyOne": { - "message": "Preenche automaticamente itens para a página atual" + "message": "Preenche itens para a página atual" }, "hasItemsVaultNudgeBodyTwo": { "message": "Favorite itens para acesso rápido" @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Por que estou vendo isso?" + }, + "resizeSideNavigation": { + "message": "Redimensionar navegação lateral" } } diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index d766e8f95fb..fdf3ba2d164 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "É necessária uma subscrição Premium para utilizar o Arquivo." }, + "itemRestored": { + "message": "O item foi restaurado" + }, "edit": { "message": "Editar" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Esta credencial está em risco e não tem um site. Adicione um site e altere a palavra-passe para uma segurança mais forte." }, + "vulnerablePassword": { + "message": "Palavra-passe vulnerável." + }, + "changeNow": { + "message": "Alterar agora" + }, "missingWebsite": { "message": "Site em falta" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Porque é que estou a ver isto?" + }, + "resizeSideNavigation": { + "message": "Redimensionar navegação lateral" } } diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index f0f71168074..44c4abba934 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Editare" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 3185edea5d5..2b96b2038a5 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Для использования архива требуется премиум-статус." }, + "itemRestored": { + "message": "Элемент восстановлен" + }, "edit": { "message": "Изменить" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Этот логин подвержен риску и у него отсутствует веб-сайт. Добавьте веб-сайт и смените пароль для большей безопасности." }, + "vulnerablePassword": { + "message": "Уязвимый пароль." + }, + "changeNow": { + "message": "Изменить сейчас" + }, "missingWebsite": { "message": "Отсутствует сайт" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Почему я это вижу?" + }, + "resizeSideNavigation": { + "message": "Изменить размер боковой навигации" } } diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 7ef837967c9..c06249e55cb 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "සංස්කරණය" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index ffda610b8f0..9c7cdec8c8f 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Na použitie archívu je potrebné prémiové členstvo." }, + "itemRestored": { + "message": "Položka bola obnovená" + }, "edit": { "message": "Upraviť" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Toto prihlásenie je v ohrození a chýba mu webová stránka. Pridajte webovú stránku a zmeňte heslo na silnejšie zabezpečenie." }, + "vulnerablePassword": { + "message": "Zraniteľné heslo." + }, + "changeNow": { + "message": "Zmeniť teraz" + }, "missingWebsite": { "message": "Chýbajúca webová stránka" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Prečo to vidím?" + }, + "resizeSideNavigation": { + "message": "Zmeniť veľkosť bočnej navigácie" } } diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 2806020d5f0..7d9eef643a3 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Uredi" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 4a23b7e7141..223d5909d41 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Премијум чланство је неопходно за употребу Архиве." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Уреди" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Ова пријава је ризична и недостаје веб локација. Додајте веб страницу и промените лозинку за јачу сигурност." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Недостаје веб страница" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Зашто видите ово?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index d04202f5c0b..4a9fc27dd84 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Ett premium-medlemskap krävs för att använda Arkiv." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Redigera" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Denna inloggning är utsatt för risk och saknar en webbplats. Lägg till en webbplats och ändra lösenordet för ökad säkerhet." }, + "vulnerablePassword": { + "message": "Sårbart lösenord." + }, + "changeNow": { + "message": "Ändra nu" + }, "missingWebsite": { "message": "Saknar webbplats" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Varför ser jag det här?" + }, + "resizeSideNavigation": { + "message": "Ändra storlek på sidnavigering" } } diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index 1e25b5157ef..d11b2329b3f 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "காப்பகத்தைப் பயன்படுத்த பிரீமியம் உறுப்பினர் தேவை." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "திருத்து" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "இந்த உள்நுழைவு ஆபத்தில் உள்ளது, மேலும் அதில் ஒரு வலைத்தளமும் இல்லை. வலுவான பாதுகாப்பிற்காக ஒரு வலைத்தளத்தைச் சேர்த்து கடவுச்சொல்லை மாற்றவும்." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "காணாமல் போன வலைத்தளம்" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 04386b72930..ea4f2b08a85 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Missing website" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Why am I seeing this?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 49bda58b558..f12bed9ea18 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "ต้องเป็นสมาชิกพรีเมียมจึงจะใช้งานฟีเจอร์จัดเก็บถาวรได้" }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "แก้ไข" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "ข้อมูลเข้าสู่ระบบนี้มีความเสี่ยงและไม่มีเว็บไซต์ เพิ่มเว็บไซต์และเปลี่ยนรหัสผ่านเพื่อความปลอดภัยที่รัดกุมยิ่งขึ้น" }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "ไม่มีเว็บไซต์" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "ทำไมฉันจึงเห็นสิ่งนี้" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index e6a787716a4..84b240c2397 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Arşivi kullanmak için premium üyelik gereklidir." }, + "itemRestored": { + "message": "Kayıt geri yüklendi" + }, "edit": { "message": "Düzenle" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Bu hesap risk altında ve web sitesi eksik. Bir web sitesi ekleyin ve güvenliğinizi artırmak için parolayı değiştirin." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Web sitesi eksik" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Bunu neden görüyorum?" + }, + "resizeSideNavigation": { + "message": "Kenar menüsünü yeniden boyutlandır" } } diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index d777dee1472..2688995d6a7 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Для використання архіву необхідна передплата Premium." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Змінити" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Цей запис ризикований, і не має адреси вебсайту. Додайте адресу вебсайту і змініть пароль для вдосконалення безпеки." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Немає вебсайту" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Чому я це бачу?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index cd8ad7cbc91..e00aae84e50 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "Cần là thành viên cao cấp để sử dụng tính năng Lưu trữ." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Sửa" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Thông tin đăng nhập này có rủi ro và thiếu một trang web. Hãy thêm trang web và đổi mật khẩu để tăng cường bảo mật." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Thiếu trang web" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "Tại sao tôi thấy điều này?" + }, + "resizeSideNavigation": { + "message": "Resize side navigation" } } diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index c7f7fbcd618..ef2ac258078 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "需要高级会员才能使用归档。" }, + "itemRestored": { + "message": "项目已恢复" + }, "edit": { "message": "编辑" }, @@ -4856,7 +4859,7 @@ "message": "新增" }, "removeItem": { - "message": "删除 $NAME$", + "message": "移除 $NAME$", "description": "Remove a selected option, such as a folder or collection", "placeholders": { "name": { @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "此登录存在风险且缺少网站。请添加网站并更改密码以增强安全性。" }, + "vulnerablePassword": { + "message": "易受攻击的密码。" + }, + "changeNow": { + "message": "立即更改" + }, "missingWebsite": { "message": "缺少网站" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "为什么我会看到这个?" + }, + "resizeSideNavigation": { + "message": "调整侧边导航栏大小" } } diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index abb25c48b43..b43739639c5 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -585,6 +585,9 @@ "upgradeToUseArchive": { "message": "需要進階版會員才能使用封存功能。" }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "編輯" }, @@ -5664,6 +5667,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "此登入資訊存在風險,且缺少網站。請新增網站並變更密碼以提升安全性。" }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "缺少網站" }, @@ -6039,5 +6048,8 @@ }, "whyAmISeeingThis": { "message": "為什麼我會看到此訊息?" + }, + "resizeSideNavigation": { + "message": "調整側邊欄大小" } } diff --git a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts index ebabbadf71c..d1380f5eae0 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts @@ -8,6 +8,7 @@ import { firstValueFrom, of, BehaviorSubject } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { NudgesService } from "@bitwarden/angular/vault"; import { LockService } from "@bitwarden/auth/common"; +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -124,6 +125,12 @@ describe("AccountSecurityComponent", () => { { provide: ToastService, useValue: mock() }, { provide: UserVerificationService, useValue: mock() }, { provide: ValidationService, useValue: validationService }, + { provide: LockService, useValue: lockService }, + { + provide: AutomaticUserConfirmationService, + useValue: mock(), + }, + { provide: ConfigService, useValue: configService }, { provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService }, ], }) diff --git a/apps/browser/src/auth/services/auth-request-answering/extension-auth-request-answering.service.spec.ts b/apps/browser/src/auth/services/auth-request-answering/extension-auth-request-answering.service.spec.ts new file mode 100644 index 00000000000..4817576a8e0 --- /dev/null +++ b/apps/browser/src/auth/services/auth-request-answering/extension-auth-request-answering.service.spec.ts @@ -0,0 +1,242 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ActionsService } from "@bitwarden/common/platform/actions"; +import { + ButtonLocation, + SystemNotificationEvent, + SystemNotificationsService, +} from "@bitwarden/common/platform/system-notifications/system-notifications.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { ExtensionAuthRequestAnsweringService } from "./extension-auth-request-answering.service"; + +describe("ExtensionAuthRequestAnsweringService", () => { + let accountService: MockProxy; + let authService: MockProxy; + let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$ + let messagingService: MockProxy; + let pendingAuthRequestsState: MockProxy; + let actionService: MockProxy; + let i18nService: MockProxy; + let platformUtilsService: MockProxy; + let systemNotificationsService: MockProxy; + let logService: MockProxy; + + let sut: AuthRequestAnsweringService; + + const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId; + const userAccountInfo = mockAccountInfoWith({ + name: "User", + email: "user@example.com", + }); + const userAccount: Account = { + id: userId, + ...userAccountInfo, + }; + + const authRequestId = "auth-request-id-123"; + + beforeEach(() => { + accountService = mock(); + authService = mock(); + masterPasswordService = { + forceSetPasswordReason$: jest.fn().mockReturnValue(of(ForceSetPasswordReason.None)), + }; + messagingService = mock(); + pendingAuthRequestsState = mock(); + actionService = mock(); + i18nService = mock(); + platformUtilsService = mock(); + systemNotificationsService = mock(); + logService = mock(); + + // Common defaults + authService.activeAccountStatus$ = of(AuthenticationStatus.Locked); + accountService.activeAccount$ = of(userAccount); + accountService.accounts$ = of({ + [userId]: userAccountInfo, + }); + platformUtilsService.isPopupOpen.mockResolvedValue(false); + i18nService.t.mockImplementation( + (key: string, p1?: any) => `${key}${p1 != null ? ":" + p1 : ""}`, + ); + systemNotificationsService.create.mockResolvedValue("notif-id"); + + sut = new ExtensionAuthRequestAnsweringService( + accountService, + authService, + masterPasswordService, + messagingService, + pendingAuthRequestsState, + actionService, + i18nService, + platformUtilsService, + systemNotificationsService, + logService, + ); + }); + + describe("receivedPendingAuthRequest()", () => { + it("should throw if authRequestUserId not given", async () => { + // Act + const promise = sut.receivedPendingAuthRequest(undefined, authRequestId); + + // Assert + await expect(promise).rejects.toThrow("authRequestUserId required"); + }); + + it("should throw if authRequestId not given", async () => { + // Act + const promise = sut.receivedPendingAuthRequest(userId, undefined); + + // Assert + await expect(promise).rejects.toThrow("authRequestId required"); + }); + + it("should add a pending marker for the user to state", async () => { + // Act + await sut.receivedPendingAuthRequest(userId, authRequestId); + + // Assert + expect(pendingAuthRequestsState.add).toHaveBeenCalledTimes(1); + expect(pendingAuthRequestsState.add).toHaveBeenCalledWith(userId); + }); + + describe("given the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", () => { + describe("given the popup is open", () => { + it("should send an 'openLoginApproval' message", async () => { + // Arrange + platformUtilsService.isPopupOpen.mockResolvedValue(true); + authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); + + // Act + await sut.receivedPendingAuthRequest(userId, authRequestId); + + // Assert + expect(messagingService.send).toHaveBeenCalledTimes(1); + expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", { + notificationId: authRequestId, + }); + }); + + it("should not create a system notification", async () => { + // Arrange + platformUtilsService.isPopupOpen.mockResolvedValue(true); + authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); + + // Act + await sut.receivedPendingAuthRequest(userId, authRequestId); + + // Assert + expect(systemNotificationsService.create).not.toHaveBeenCalled(); + }); + }); + + describe("given the popup is closed", () => { + it("should not send an 'openLoginApproval' message", async () => { + // Arrange + platformUtilsService.isPopupOpen.mockResolvedValue(false); + authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); + + // Act + await sut.receivedPendingAuthRequest(userId, authRequestId); + + // Assert + expect(messagingService.send).not.toHaveBeenCalled(); + }); + + it("should create a system notification", async () => { + // Arrange + platformUtilsService.isPopupOpen.mockResolvedValue(false); + authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); + + // Act + await sut.receivedPendingAuthRequest(userId, authRequestId); + + // Assert + expect(i18nService.t).toHaveBeenCalledWith("accountAccessRequested"); + expect(i18nService.t).toHaveBeenCalledWith("confirmAccessAttempt", "user@example.com"); + expect(systemNotificationsService.create).toHaveBeenCalledWith({ + id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, + title: "accountAccessRequested", + body: "confirmAccessAttempt:user@example.com", + buttons: [], + }); + }); + }); + }); + }); + + describe("activeUserMeetsConditionsToShowApprovalDialog()", () => { + describe("given the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", () => { + it("should return true if popup is open", async () => { + // Arrange + platformUtilsService.isPopupOpen.mockResolvedValue(true); + authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); + + // Act + const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId); + + // Assert + expect(result).toBe(true); + }); + + it("should return false if popup is closed", async () => { + // Arrange + platformUtilsService.isPopupOpen.mockResolvedValue(false); + authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); + + // Act + const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId); + + // Assert + expect(result).toBe(false); + }); + }); + }); + + describe("handleAuthRequestNotificationClicked()", () => { + it("should clear notification and open popup when notification body is clicked", async () => { + // Arrange + const event: SystemNotificationEvent = { + id: "123", + buttonIdentifier: ButtonLocation.NotificationButton, + }; + + // Act + await sut.handleAuthRequestNotificationClicked(event); + + // Assert + expect(systemNotificationsService.clear).toHaveBeenCalledWith({ id: "123" }); + expect(actionService.openPopup).toHaveBeenCalledTimes(1); + }); + + it("should do nothing when an optional notification button is clicked", async () => { + // Arrange + const event: SystemNotificationEvent = { + id: "123", + buttonIdentifier: ButtonLocation.FirstOptionalButton, + }; + + // Act + await sut.handleAuthRequestNotificationClicked(event); + + // Assert + expect(systemNotificationsService.clear).not.toHaveBeenCalled(); + expect(actionService.openPopup).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/auth/services/auth-request-answering/extension-auth-request-answering.service.ts b/apps/browser/src/auth/services/auth-request-answering/extension-auth-request-answering.service.ts new file mode 100644 index 00000000000..988de685978 --- /dev/null +++ b/apps/browser/src/auth/services/auth-request-answering/extension-auth-request-answering.service.ts @@ -0,0 +1,116 @@ +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags"; +import { DefaultAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/default-auth-request-answering.service"; +import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; +import { MasterPasswordServiceAbstraction } 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ActionsService } from "@bitwarden/common/platform/actions"; +import { + ButtonLocation, + SystemNotificationEvent, + SystemNotificationsService, +} from "@bitwarden/common/platform/system-notifications/system-notifications.service"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +export class ExtensionAuthRequestAnsweringService + extends DefaultAuthRequestAnsweringService + implements AuthRequestAnsweringService +{ + constructor( + protected readonly accountService: AccountService, + protected readonly authService: AuthService, + protected readonly masterPasswordService: MasterPasswordServiceAbstraction, + protected readonly messagingService: MessagingService, + protected readonly pendingAuthRequestsState: PendingAuthRequestsStateService, + private readonly actionService: ActionsService, + private readonly i18nService: I18nService, + private readonly platformUtilsService: PlatformUtilsService, + private readonly systemNotificationsService: SystemNotificationsService, + private readonly logService: LogService, + ) { + super( + accountService, + authService, + masterPasswordService, + messagingService, + pendingAuthRequestsState, + ); + } + + async receivedPendingAuthRequest( + authRequestUserId: UserId, + authRequestId: string, + ): Promise { + if (!authRequestUserId) { + throw new Error("authRequestUserId required"); + } + if (!authRequestId) { + throw new Error("authRequestId required"); + } + + // Always persist the pending marker for this user to global state. + await this.pendingAuthRequestsState.add(authRequestUserId); + + const activeUserMeetsConditionsToShowApprovalDialog = + await this.activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId); + + if (activeUserMeetsConditionsToShowApprovalDialog) { + // Send message to open dialog immediately for this request + this.messagingService.send("openLoginApproval", { + // Include the authRequestId so the DeviceManagementComponent can upsert the correct device. + // This will only matter if the user is on the /device-management screen when the auth request is received. + notificationId: authRequestId, + }); + } else { + // Create a system notification + const accounts = await firstValueFrom(this.accountService.accounts$); + const accountInfo = accounts[authRequestUserId]; + + if (!accountInfo) { + this.logService.error("Account not found for authRequestUserId"); + return; + } + + const emailForUser = accountInfo.email; + await this.systemNotificationsService.create({ + id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, // the underscore is an important delimiter. + title: this.i18nService.t("accountAccessRequested"), + body: this.i18nService.t("confirmAccessAttempt", emailForUser), + buttons: [], + }); + } + } + + async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise { + const meetsBasicConditions = await super.activeUserMeetsConditionsToShowApprovalDialog( + authRequestUserId, + ); + + // To show an approval dialog immediately on Extension, the popup must be open. + const isPopupOpen = await this.platformUtilsService.isPopupOpen(); + const meetsExtensionConditions = meetsBasicConditions && isPopupOpen; + + return meetsExtensionConditions; + } + + /** + * When a system notification is clicked, this function is used to process that event. + * + * @param event The event passed in. Check initNotificationSubscriptions in main.background.ts. + */ + async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise { + if (event.buttonIdentifier === ButtonLocation.NotificationButton) { + await this.systemNotificationsService.clear({ + id: `${event.id}`, + }); + await this.actionService.openPopup(); + } + } +} diff --git a/apps/browser/src/autofill/fido2/content/fido2-content-script.ts b/apps/browser/src/autofill/fido2/content/fido2-content-script.ts index 03816f2b382..9257ac748bb 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-content-script.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-content-script.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { AssertCredentialParams, CreateCredentialParams, @@ -41,12 +39,12 @@ import { MessageWithMetadata, Messenger } from "./messaging/messenger"; */ async function handleFido2Message( message: MessageWithMetadata, - abortController: AbortController, + abortController?: AbortController, ) { const requestId = Date.now().toString(); const abortHandler = () => sendExtensionMessage("fido2AbortRequest", { abortedRequestId: requestId }); - abortController.signal.addEventListener("abort", abortHandler); + abortController?.signal.addEventListener("abort", abortHandler); try { if (message.type === MessageTypes.CredentialCreationRequest) { @@ -67,7 +65,7 @@ import { MessageWithMetadata, Messenger } from "./messaging/messenger"; return sendExtensionMessage("fido2AbortRequest", { abortedRequestId: requestId }); } } finally { - abortController.signal.removeEventListener("abort", abortHandler); + abortController?.signal.removeEventListener("abort", abortHandler); } } diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 817a7cca43c..7ea89e114ab 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -1086,15 +1086,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ pageDetails, ) ) { - const hasUsernameField = [...this.formFieldElements.values()].some((field) => - this.inlineMenuFieldQualificationService.isUsernameField(field), - ); - - if (hasUsernameField) { - void this.setQualifiedLoginFillType(autofillFieldData); - } else { - this.setQualifiedAccountCreationFillType(autofillFieldData); - } + this.setQualifiedAccountCreationFillType(autofillFieldData); return false; } @@ -1659,17 +1651,19 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ return false; }; const scrollHandler = this.useEventHandlersMemo( - throttle(async (event) => { + throttle(async (event: Event) => { + const scrollY = globalThis.scrollY; + const scrollX = globalThis.scrollX; if ( - currentScrollY !== globalThis.scrollY || - currentScrollX !== globalThis.scrollX || - eventTargetContainsFocusedField(event.target) + currentScrollY !== scrollY || + currentScrollX !== scrollX || + (event.target instanceof Element && eventTargetContainsFocusedField(event.target)) ) { repositionHandler(event); } - currentScrollY = globalThis.scrollY; - currentScrollX = globalThis.scrollX; + currentScrollY = scrollY; + currentScrollX = scrollX; }, 50), AUTOFILL_OVERLAY_HANDLE_SCROLL, ); diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index a3d61c7f0b2..dc07ca1e258 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { FieldRect } from "../background/abstractions/overlay.background"; import { AutofillPort } from "../enums/autofill-port.enum"; import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types"; @@ -144,11 +142,14 @@ export function setElementStyles( } for (const styleProperty in styles) { - element.style.setProperty( - styleProperty.replace(/([a-z])([A-Z])/g, "$1-$2"), // Convert camelCase to kebab-case - styles[styleProperty], - priority ? "important" : undefined, - ); + const styleValue = styles[styleProperty]; + if (styleValue !== undefined) { + element.style.setProperty( + styleProperty.replace(/([a-z])([A-Z])/g, "$1-$2"), // Convert camelCase to kebab-case + styleValue, + priority ? "important" : undefined, + ); + } } } @@ -175,12 +176,13 @@ export function setupExtensionDisconnectAction(callback: (port: chrome.runtime.P * @param windowContext - The global window context */ export function setupAutofillInitDisconnectAction(windowContext: Window) { - if (!windowContext.bitwardenAutofillInit) { + const bitwardenAutofillInit = windowContext.bitwardenAutofillInit; + if (!bitwardenAutofillInit) { return; } const onDisconnectCallback = () => { - windowContext.bitwardenAutofillInit.destroy(); + bitwardenAutofillInit.destroy(); delete windowContext.bitwardenAutofillInit; }; setupExtensionDisconnectAction(onDisconnectCallback); @@ -357,7 +359,7 @@ export function getAttributeBoolean( */ export function getPropertyOrAttribute(element: HTMLElement, attributeName: string): string | null { if (attributeName in element) { - return (element as FormElementWithAttribute)[attributeName]; + return (element as FormElementWithAttribute)[attributeName] ?? null; } return element.getAttribute(attributeName); @@ -369,9 +371,12 @@ export function getPropertyOrAttribute(element: HTMLElement, attributeName: stri * @param callback - The callback function to throttle. * @param limit - The time in milliseconds to throttle the callback. */ -export function throttle(callback: (_args: any) => any, limit: number) { +export function throttle unknown>( + callback: FunctionType, + limit: number, +): (this: ThisParameterType, ...args: Parameters) => void { let waitingDelay = false; - return function (...args: unknown[]) { + return function (this: ThisParameterType, ...args: Parameters) { if (!waitingDelay) { callback.apply(this, args); waitingDelay = true; @@ -387,9 +392,14 @@ export function throttle(callback: (_args: any) => any, limit: number) { * @param delay - The time in milliseconds to debounce the callback. * @param immediate - Determines whether the callback should run immediately. */ -export function debounce(callback: (_args: any) => any, delay: number, immediate?: boolean) { - let timeout: NodeJS.Timeout; - return function (...args: unknown[]) { +export function debounce unknown>( + callback: FunctionType, + delay: number, + immediate?: boolean, +): (this: ThisParameterType, ...args: Parameters) => void { + let timeout: ReturnType | null = null; + + return function (this: ThisParameterType, ...args: Parameters) { const callImmediately = !!immediate && !timeout; if (timeout) { @@ -430,16 +440,17 @@ export function getSubmitButtonKeywordsSet(element: HTMLElement): Set { const keywordsSet = new Set(); for (let i = 0; i < keywords.length; i++) { - if (typeof keywords[i] === "string") { + const keyword = keywords[i]; + if (typeof keyword === "string") { // Iterate over all keywords metadata and split them by non-letter characters. // This ensures we check against individual words and not the entire string. - keywords[i] + keyword .toLowerCase() .replace(/[-\s]/g, "") .split(/[^\p{L}]+/gu) - .forEach((keyword) => { - if (keyword) { - keywordsSet.add(keyword); + .forEach((splitKeyword) => { + if (splitKeyword) { + keywordsSet.add(splitKeyword); } }); } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 3cd8b59aabc..b9b41943b04 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -40,7 +40,7 @@ import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/p import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; -import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; @@ -52,7 +52,6 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@ import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; -import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service"; import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; @@ -275,6 +274,7 @@ import { VaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; +import { ExtensionAuthRequestAnsweringService } from "../auth/services/auth-request-answering/extension-auth-request-answering.service"; import { AuthStatusBadgeUpdaterService } from "../auth/services/auth-status-badge-updater.service"; import { ExtensionLockService } from "../auth/services/extension-lock.service"; import { OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface } from "../autofill/background/abstractions/overlay-notifications.background"; @@ -392,7 +392,7 @@ export default class MainBackground { serverNotificationsService: ServerNotificationsService; systemNotificationService: SystemNotificationsService; actionsService: ActionsService; - authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction; + authRequestAnsweringService: AuthRequestAnsweringService; stateService: StateServiceAbstraction; userNotificationSettingsService: UserNotificationSettingsServiceAbstraction; autofillSettingsService: AutofillSettingsServiceAbstraction; @@ -1208,16 +1208,17 @@ export default class MainBackground { this.pendingAuthRequestStateService = new PendingAuthRequestsStateService(this.stateProvider); - this.authRequestAnsweringService = new AuthRequestAnsweringService( + this.authRequestAnsweringService = new ExtensionAuthRequestAnsweringService( this.accountService, - this.actionsService, this.authService, - this.i18nService, this.masterPasswordService, this.messagingService, this.pendingAuthRequestStateService, + this.actionsService, + this.i18nService, this.platformUtilsService, this.systemNotificationService, + this.logService, ); this.serverNotificationsService = new DefaultServerNotificationsService( diff --git a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts index 262d6cf833b..4cd155c8ae3 100644 --- a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts +++ b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts @@ -18,8 +18,7 @@ export const PHISHING_RESOURCES: Record { +describe("foreground background memory storage interaction", () => { let foreground: ForegroundMemoryStorageService; let background: BackgroundMemoryStorageService; let logService: MockProxy; @@ -28,7 +27,7 @@ describe.skip("foreground background memory storage interaction", () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); test.each(["has", "get"])( diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 12e1288e806..1f1d4d25b40 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -42,6 +42,7 @@ import { TwoFactorAuthComponent, TwoFactorAuthGuard, } from "@bitwarden/auth/angular"; +import { canAccessAutoConfirmSettings } from "@bitwarden/auto-confirm"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; import { LockComponent, @@ -85,11 +86,13 @@ import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/v import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component"; import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component"; import { + atRiskPasswordAuthGuard, canAccessAtRiskPasswords, hasAtRiskPasswords, } from "../vault/popup/guards/at-risk-passwords.guard"; import { clearVaultStateGuard } from "../vault/popup/guards/clear-vault-state.guard"; import { IntroCarouselGuard } from "../vault/popup/guards/intro-carousel.guard"; +import { AdminSettingsComponent } from "../vault/popup/settings/admin-settings.component"; import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component"; import { ArchiveComponent } from "../vault/popup/settings/archive.component"; import { DownloadBitwardenComponent } from "../vault/popup/settings/download-bitwarden.component"; @@ -332,6 +335,12 @@ const routes: Routes = [ canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, + { + path: "admin", + component: AdminSettingsComponent, + canActivate: [authGuard, canAccessAutoConfirmSettings], + data: { elevation: 1 } satisfies RouteDataProperties, + }, { path: "clone-cipher", component: AddEditV2Component, @@ -715,7 +724,7 @@ const routes: Routes = [ { path: "at-risk-passwords", component: AtRiskPasswordsComponent, - canActivate: [authGuard, canAccessAtRiskPasswords, hasAtRiskPasswords], + canActivate: [atRiskPasswordAuthGuard, canAccessAtRiskPasswords, hasAtRiskPasswords], }, { path: AuthExtensionRoute.AccountSwitcher, diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 8f00569b720..e4cb8a654c4 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -14,16 +14,11 @@ import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { catchError, concatMap, - distinctUntilChanged, filter, firstValueFrom, map, of, - pairwise, - startWith, Subject, - switchMap, - take, takeUntil, tap, } from "rxjs"; @@ -38,7 +33,7 @@ import { } from "@bitwarden/auth/common"; import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -83,7 +78,8 @@ export class AppComponent implements OnInit, OnDestroy { private lastActivity: Date; private activeUserId: UserId; private routerAnimations = false; - private processingPendingAuth = false; + private processingPendingAuthRequests = false; + private shouldRerunAuthRequestProcessing = false; private destroy$ = new Subject(); @@ -118,7 +114,7 @@ export class AppComponent implements OnInit, OnDestroy { private logService: LogService, private authRequestService: AuthRequestServiceAbstraction, private pendingAuthRequestsState: PendingAuthRequestsStateService, - private authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction, + private authRequestAnsweringService: AuthRequestAnsweringService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -136,22 +132,7 @@ export class AppComponent implements OnInit, OnDestroy { this.activeUserId = account?.id; }); - // Trigger processing auth requests when the active user is in an unlocked state. Runs once when - // the popup is open. - this.accountService.activeAccount$ - .pipe( - map((a) => a?.id), // Extract active userId - distinctUntilChanged(), // Only when userId actually changes - filter((userId) => userId != null), // Require a valid userId - switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user - filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked - tap(() => { - // Trigger processing when switching users while popup is open - void this.authRequestAnsweringService.processPendingAuthRequests(); - }), - takeUntil(this.destroy$), - ) - .subscribe(); + this.authRequestAnsweringService.setupUnlockListenersForProcessingAuthRequests(this.destroy$); this.authService.activeAccountStatus$ .pipe( @@ -163,23 +144,6 @@ export class AppComponent implements OnInit, OnDestroy { ) .subscribe(); - // When the popup is already open and the active account transitions to Unlocked, - // process any pending auth requests for the active user. The above subscription does not handle - // this case. - this.authService.activeAccountStatus$ - .pipe( - startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission - pairwise(), // Compare previous and current statuses - filter( - ([prev, curr]) => - prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial) - ), - takeUntil(this.destroy$), - ) - .subscribe(() => { - void this.authRequestAnsweringService.processPendingAuthRequests(); - }); - this.ngZone.runOutsideAngular(() => { window.onmousedown = () => this.recordActivity(); window.ontouchstart = () => this.recordActivity(); @@ -234,38 +198,31 @@ export class AppComponent implements OnInit, OnDestroy { await this.router.navigate(["lock"]); } else if (msg.command === "openLoginApproval") { - if (this.processingPendingAuth) { + if (this.processingPendingAuthRequests) { + // If an "openLoginApproval" message is received while we are currently processing other + // auth requests, then set a flag so we remember to process that new auth request + this.shouldRerunAuthRequestProcessing = true; return; } - this.processingPendingAuth = true; - try { - // Always query server for all pending requests and open a dialog for each - const pendingList = await firstValueFrom( - this.authRequestService.getPendingAuthRequests$(), - ); - if (Array.isArray(pendingList) && pendingList.length > 0) { - const respondedIds = new Set(); - for (const req of pendingList) { - if (req?.id == null) { - continue; - } - const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, { - notificationId: req.id, - }); - const result = await firstValueFrom(dialogRef.closed); + /** + * This do/while loop allows us to: + * - a) call processPendingAuthRequests() once on "openLoginApproval" + * - b) remember to re-call processPendingAuthRequests() if another "openLoginApproval" was + * received while we were processing the original auth requests + */ + do { + this.shouldRerunAuthRequestProcessing = false; - if (result !== undefined && typeof result === "boolean") { - respondedIds.add(req.id); - if (respondedIds.size === pendingList.length && this.activeUserId != null) { - await this.pendingAuthRequestsState.clear(this.activeUserId); - } - } - } + try { + await this.processPendingAuthRequests(); + } catch (error) { + this.logService.error(`Error processing pending auth requests: ${error}`); + this.shouldRerunAuthRequestProcessing = false; // Reset flag to prevent infinite loop on persistent errors } - } finally { - this.processingPendingAuth = false; - } + // If an "openLoginApproval" message was received while processPendingAuthRequests() was running, then + // shouldRerunAuthRequestProcessing will have been set to true + } while (this.shouldRerunAuthRequestProcessing); } else if (msg.command === "showDialog") { // 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 @@ -403,4 +360,39 @@ export class AppComponent implements OnInit, OnDestroy { this.toastService.showToast(toastOptions); } + + private async processPendingAuthRequests() { + this.processingPendingAuthRequests = true; + + try { + // Always query server for all pending requests and open a dialog for each + const pendingList = await firstValueFrom(this.authRequestService.getPendingAuthRequests$()); + + if (Array.isArray(pendingList) && pendingList.length > 0) { + const respondedIds = new Set(); + + for (const req of pendingList) { + if (req?.id == null) { + continue; + } + + const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, { + notificationId: req.id, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if (result !== undefined && typeof result === "boolean") { + respondedIds.add(req.id); + + if (respondedIds.size === pendingList.length && this.activeUserId != null) { + await this.pendingAuthRequestsState.clear(this.activeUserId); + } + } + } + } + } finally { + this.processingPendingAuthRequests = false; + } + } } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7a2ded5bb83..c462e798a42 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -3,7 +3,11 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; import { merge, of, Subject } from "rxjs"; -import { CollectionService } from "@bitwarden/admin-console/common"; +import { + CollectionService, + OrganizationUserApiService, + OrganizationUserService, +} from "@bitwarden/admin-console/common"; import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction"; import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password"; import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service"; @@ -40,22 +44,29 @@ import { LogoutService, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; +import { + AutomaticUserConfirmationService, + DefaultAutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; +import { ExtensionAuthRequestAnsweringService } from "@bitwarden/browser/auth/services/auth-request-answering/extension-auth-request-answering.service"; import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { + InternalOrganizationServiceAbstraction, + OrganizationService, +} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService, AccountService as AccountServiceAbstraction, } from "@bitwarden/common/auth/abstractions/account.service"; -import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service"; import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; import { AutofillSettingsService, @@ -494,18 +505,19 @@ const safeProviders: SafeProvider[] = [ deps: [], }), safeProvider({ - provide: AuthRequestAnsweringServiceAbstraction, - useClass: AuthRequestAnsweringService, + provide: AuthRequestAnsweringService, + useClass: ExtensionAuthRequestAnsweringService, deps: [ AccountServiceAbstraction, - ActionsService, AuthService, - I18nServiceAbstraction, MasterPasswordServiceAbstraction, MessagingService, PendingAuthRequestsStateService, + ActionsService, + I18nServiceAbstraction, PlatformUtilsService, SystemNotificationsService, + LogService, ], }), safeProvider({ @@ -744,6 +756,19 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionNewDeviceVerificationComponentService, deps: [], }), + safeProvider({ + provide: AutomaticUserConfirmationService, + useClass: DefaultAutomaticUserConfirmationService, + deps: [ + ConfigService, + ApiService, + OrganizationUserService, + StateProvider, + InternalOrganizationServiceAbstraction, + OrganizationUserApiService, + PolicyService, + ], + }), safeProvider({ provide: SessionTimeoutTypeService, useClass: BrowserSessionTimeoutTypeService, diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.html b/apps/browser/src/tools/popup/settings/settings-v2.component.html index 06c89e15f59..c6f1c9dbc3b 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -82,6 +82,24 @@ + + @if (showAdminSettingsLink$ | async) { + + + +
+

{{ "admin" | i18n }}

+ @if (showAdminBadge$ | async) { + 1 + } +
+ +
+
+ } + diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.spec.ts b/apps/browser/src/tools/popup/settings/settings-v2.component.spec.ts index 4cc3ed0149c..a05fa45753e 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.spec.ts @@ -6,6 +6,7 @@ import { BehaviorSubject, firstValueFrom, of, Subject } from "rxjs"; import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { AutofillBrowserSettingsService } from "@bitwarden/browser/autofill/services/autofill-browser-settings.service"; import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -42,6 +43,9 @@ describe("SettingsV2Component", () => { defaultBrowserAutofillDisabled$: Subject; isBrowserAutofillSettingOverridden: jest.Mock>; }; + let mockAutoConfirmService: { + canManageAutoConfirm$: jest.Mock; + }; let dialogService: MockProxy; let openSpy: jest.SpyInstance; @@ -66,6 +70,10 @@ describe("SettingsV2Component", () => { isBrowserAutofillSettingOverridden: jest.fn().mockResolvedValue(false), }; + mockAutoConfirmService = { + canManageAutoConfirm$: jest.fn().mockReturnValue(of(false)), + }; + jest.spyOn(BrowserApi, "getBrowserClientVendor").mockReturnValue("Chrome"); const cfg = TestBed.configureTestingModule({ @@ -75,6 +83,7 @@ describe("SettingsV2Component", () => { { provide: BillingAccountProfileStateService, useValue: mockBillingState }, { provide: NudgesService, useValue: mockNudges }, { provide: AutofillBrowserSettingsService, useValue: mockAutofillSettings }, + { provide: AutomaticUserConfirmationService, useValue: mockAutoConfirmService }, { provide: DialogService, useValue: dialogService }, { provide: I18nService, useValue: { t: jest.fn((key: string) => key) } }, { provide: GlobalStateProvider, useValue: new FakeGlobalStateProvider() }, diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.ts b/apps/browser/src/tools/popup/settings/settings-v2.component.ts index e10d41b9445..2c9f893c99c 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.ts @@ -7,7 +7,9 @@ import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/compon import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { UserId } from "@bitwarden/common/types/guid"; import { @@ -65,13 +67,25 @@ export class SettingsV2Component { ), ); + showAdminBadge$: Observable = this.authenticatedAccount$.pipe( + switchMap((account) => + this.nudgesService.showNudgeBadge$(NudgeType.AutoConfirmNudge, account.id), + ), + ); + showAutofillBadge$: Observable = this.authenticatedAccount$.pipe( switchMap((account) => this.nudgesService.showNudgeBadge$(NudgeType.AutofillNudge, account.id)), ); + showAdminSettingsLink$: Observable = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.autoConfirmService.canManageAutoConfirm$(userId)), + ); + constructor( private readonly nudgesService: NudgesService, private readonly accountService: AccountService, + private readonly autoConfirmService: AutomaticUserConfirmationService, private readonly accountProfileStateService: BillingAccountProfileStateService, private readonly dialogService: DialogService, ) {} diff --git a/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts b/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts index 03111859165..1b279e1078d 100644 --- a/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts +++ b/apps/browser/src/vault/popup/guards/at-risk-passwords.guard.ts @@ -1,7 +1,13 @@ import { inject } from "@angular/core"; -import { CanActivateFn, Router } from "@angular/router"; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, +} from "@angular/router"; import { combineLatest, map, switchMap } from "rxjs"; +import { authGuard } from "@bitwarden/angular/auth/guards"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -9,6 +15,24 @@ import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { ToastService } from "@bitwarden/components"; +/** + * Wrapper around the main auth guard to redirect to login if not authenticated. + * This is necessary because the main auth guard returns false when not authenticated, + * which in a browser context may result in a blank extension page rather than a redirect. + */ +export const atRiskPasswordAuthGuard: CanActivateFn = async ( + route: ActivatedRouteSnapshot, + routerState: RouterStateSnapshot, +) => { + const router = inject(Router); + + const authGuardResponse = await authGuard(route, routerState); + if (authGuardResponse === true) { + return authGuardResponse; + } + return router.createUrlTree(["/login"]); +}; + export const canAccessAtRiskPasswords: CanActivateFn = () => { const accountService = inject(AccountService); const taskService = inject(TaskService); 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 692e21d0084..866c5dd2e89 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 @@ -822,7 +822,6 @@ function createSeededVaultPopupListFiltersService( accountServiceMock, viewCacheServiceMock, restrictedItemTypesServiceMock, - configService, ); }); 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 08db7d5d4ab..439353cab50 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 @@ -29,8 +29,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -191,7 +189,6 @@ export class VaultPopupListFiltersService { private accountService: AccountService, private viewCacheService: ViewCacheService, private restrictedItemTypesService: RestrictedItemTypesService, - private configService: ConfigService, ) { this.filterForm.controls.organization.valueChanges .pipe(takeUntilDestroyed()) @@ -455,19 +452,15 @@ export class VaultPopupListFiltersService { ), this.collectionService.decryptedCollections$(userId), this.organizationService.memberOrganizations$(userId), - this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation), ]), ), - map(([filters, allCollections, orgs, defaultVaultEnabled]) => { + map(([filters, allCollections, orgs]) => { const orgFilterId = filters.organization?.id ?? null; // When the organization filter is selected, filter out collections that do not belong to the selected organization const filtered = orgFilterId ? allCollections.filter((c) => c.organizationId === orgFilterId) : allCollections; - if (!defaultVaultEnabled) { - return filtered; - } return sortDefaultCollections(filtered, orgs, this.i18nService.collator); }), map((fullList) => { diff --git a/apps/browser/src/vault/popup/settings/admin-settings.component.html b/apps/browser/src/vault/popup/settings/admin-settings.component.html new file mode 100644 index 00000000000..5e67750278f --- /dev/null +++ b/apps/browser/src/vault/popup/settings/admin-settings.component.html @@ -0,0 +1,41 @@ + + + + + + + +
+ @if (showAutoConfirmSpotlight$ | async) { + +
+ + {{ "autoConfirmOnboardingCallout" | i18n }} + + + +
+
+ } + +
+ + + + + {{ "automaticUserConfirmation" | i18n }} + + + {{ "automaticUserConfirmationHint" | i18n }} + + +
+
+
diff --git a/apps/browser/src/vault/popup/settings/admin-settings.component.spec.ts b/apps/browser/src/vault/popup/settings/admin-settings.component.spec.ts new file mode 100644 index 00000000000..f7b4e7b473a --- /dev/null +++ b/apps/browser/src/vault/popup/settings/admin-settings.component.spec.ts @@ -0,0 +1,199 @@ +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; +import { AutoConfirmState, AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; +import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; + +import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; + +import { AdminSettingsComponent } from "./admin-settings.component"; + +@Component({ + selector: "popup-header", + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockPopupHeaderComponent { + readonly pageTitle = input(); + readonly backAction = input<() => void>(); +} + +@Component({ + selector: "popup-page", + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockPopupPageComponent { + readonly loading = input(); +} + +@Component({ + selector: "app-pop-out", + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockPopOutComponent { + readonly show = input(true); +} + +describe("AdminSettingsComponent", () => { + let component: AdminSettingsComponent; + let fixture: ComponentFixture; + let autoConfirmService: MockProxy; + let nudgesService: MockProxy; + let mockDialogService: MockProxy; + + const userId = "test-user-id" as UserId; + const mockAutoConfirmState: AutoConfirmState = { + enabled: false, + showSetupDialog: true, + showBrowserNotification: false, + }; + + beforeEach(async () => { + autoConfirmService = mock(); + nudgesService = mock(); + mockDialogService = mock(); + + autoConfirmService.configuration$.mockReturnValue(of(mockAutoConfirmState)); + autoConfirmService.upsert.mockResolvedValue(undefined); + nudgesService.showNudgeSpotlight$.mockReturnValue(of(false)); + + await TestBed.configureTestingModule({ + imports: [AdminSettingsComponent], + providers: [ + provideNoopAnimations(), + { provide: AccountService, useValue: mockAccountServiceWith(userId) }, + { provide: AutomaticUserConfirmationService, useValue: autoConfirmService }, + { provide: DialogService, useValue: mockDialogService }, + { provide: NudgesService, useValue: nudgesService }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }) + .overrideComponent(AdminSettingsComponent, { + remove: { + imports: [PopupHeaderComponent, PopupPageComponent, PopOutComponent], + }, + add: { + imports: [MockPopupHeaderComponent, MockPopupPageComponent, MockPopOutComponent], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminSettingsComponent); + component = fixture.componentInstance; + }); + + describe("initialization", () => { + it("should populate form with current auto-confirm state", async () => { + const mockState: AutoConfirmState = { + enabled: true, + showSetupDialog: false, + showBrowserNotification: true, + }; + autoConfirmService.configuration$.mockReturnValue(of(mockState)); + + await component.ngOnInit(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component["adminForm"].value).toEqual({ + autoConfirm: true, + }); + }); + + it("should populate form with disabled auto-confirm state", async () => { + await component.ngOnInit(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component["adminForm"].value).toEqual({ + autoConfirm: false, + }); + }); + }); + + describe("spotlight", () => { + beforeEach(async () => { + await component.ngOnInit(); + fixture.detectChanges(); + }); + + it("should expose showAutoConfirmSpotlight$ observable", (done) => { + nudgesService.showNudgeSpotlight$.mockReturnValue(of(true)); + + const newFixture = TestBed.createComponent(AdminSettingsComponent); + const newComponent = newFixture.componentInstance; + + newComponent["showAutoConfirmSpotlight$"].subscribe((show) => { + expect(show).toBe(true); + expect(nudgesService.showNudgeSpotlight$).toHaveBeenCalledWith( + NudgeType.AutoConfirmNudge, + userId, + ); + done(); + }); + }); + + it("should dismiss spotlight and update state", async () => { + autoConfirmService.upsert.mockResolvedValue(); + + await component.dismissSpotlight(); + + expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, { + ...mockAutoConfirmState, + showBrowserNotification: false, + }); + }); + + it("should use current userId when dismissing spotlight", async () => { + autoConfirmService.upsert.mockResolvedValue(); + + await component.dismissSpotlight(); + + expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, expect.any(Object)); + }); + + it("should preserve existing state when dismissing spotlight", async () => { + const customState: AutoConfirmState = { + enabled: true, + showSetupDialog: false, + showBrowserNotification: true, + }; + autoConfirmService.configuration$.mockReturnValue(of(customState)); + autoConfirmService.upsert.mockResolvedValue(); + + await component.dismissSpotlight(); + + expect(autoConfirmService.upsert).toHaveBeenCalledWith(userId, { + ...customState, + showBrowserNotification: false, + }); + }); + }); + + describe("form validation", () => { + beforeEach(async () => { + await component.ngOnInit(); + fixture.detectChanges(); + }); + + it("should have a valid form", () => { + expect(component["adminForm"].valid).toBe(true); + }); + + it("should have autoConfirm control", () => { + expect(component["adminForm"].controls.autoConfirm).toBeDefined(); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/settings/admin-settings.component.ts b/apps/browser/src/vault/popup/settings/admin-settings.component.ts new file mode 100644 index 00000000000..e4b676525ed --- /dev/null +++ b/apps/browser/src/vault/popup/settings/admin-settings.component.ts @@ -0,0 +1,121 @@ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + OnInit, + signal, + WritableSignal, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { firstValueFrom, map, Observable, of, switchMap, tap, withLatestFrom } from "rxjs"; + +import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; +import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; +import { + AutoConfirmWarningDialogComponent, + AutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; +import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component"; +import { PopupHeaderComponent } from "@bitwarden/browser/platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "@bitwarden/browser/platform/popup/layout/popup-page.component"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { + BitIconButtonComponent, + CardComponent, + DialogService, + FormFieldModule, + SwitchComponent, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { UserId } from "@bitwarden/user-core"; + +@Component({ + templateUrl: "./admin-settings.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + PopupPageComponent, + PopupHeaderComponent, + PopOutComponent, + FormFieldModule, + ReactiveFormsModule, + SwitchComponent, + CardComponent, + SpotlightComponent, + BitIconButtonComponent, + I18nPipe, + ], +}) +export class AdminSettingsComponent implements OnInit { + private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); + + protected readonly formLoading: WritableSignal = signal(true); + protected adminForm = this.formBuilder.group({ + autoConfirm: false, + }); + protected showAutoConfirmSpotlight$: Observable = this.userId$.pipe( + switchMap((userId) => + this.nudgesService.showNudgeSpotlight$(NudgeType.AutoConfirmNudge, userId), + ), + ); + + constructor( + private formBuilder: FormBuilder, + private accountService: AccountService, + private autoConfirmService: AutomaticUserConfirmationService, + private destroyRef: DestroyRef, + private dialogService: DialogService, + private nudgesService: NudgesService, + ) {} + + async ngOnInit() { + const userId = await firstValueFrom(this.userId$); + const autoConfirmEnabled = ( + await firstValueFrom(this.autoConfirmService.configuration$(userId)) + ).enabled; + this.adminForm.setValue({ autoConfirm: autoConfirmEnabled }); + + this.formLoading.set(false); + + this.adminForm.controls.autoConfirm.valueChanges + .pipe( + switchMap((newValue) => { + if (newValue) { + return this.confirm(); + } + return of(false); + }), + withLatestFrom(this.autoConfirmService.configuration$(userId)), + switchMap(([newValue, existingState]) => + this.autoConfirmService.upsert(userId, { + ...existingState, + enabled: newValue, + showBrowserNotification: false, + }), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + } + + private confirm(): Observable { + return AutoConfirmWarningDialogComponent.open(this.dialogService).closed.pipe( + map((result) => result ?? false), + tap((result) => { + if (!result) { + this.adminForm.setValue({ autoConfirm: false }, { emitEvent: false }); + } + }), + ); + } + + async dismissSpotlight() { + const userId = await firstValueFrom(this.userId$); + const state = await firstValueFrom(this.autoConfirmService.configuration$(userId)); + + await this.autoConfirmService.upsert(userId, { ...state, showBrowserNotification: false }); + } +} diff --git a/apps/cli/src/admin-console/commands/confirm.command.ts b/apps/cli/src/admin-console/commands/confirm.command.ts index adae5091172..4458054e244 100644 --- a/apps/cli/src/admin-console/commands/confirm.command.ts +++ b/apps/cli/src/admin-console/commands/confirm.command.ts @@ -9,9 +9,7 @@ import { import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { 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 { Utils } from "@bitwarden/common/platform/misc/utils"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -28,7 +26,6 @@ export class ConfirmCommand { private encryptService: EncryptService, private organizationUserApiService: OrganizationUserApiService, private accountService: AccountService, - private configService: ConfigService, private i18nService: I18nService, ) {} @@ -80,11 +77,7 @@ export class ConfirmCommand { const key = await this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey); const req = new OrganizationUserConfirmRequest(); req.key = key.encryptedString; - if ( - await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)) - ) { - req.defaultUserCollectionName = await this.getEncryptedDefaultUserCollectionName(orgKey); - } + req.defaultUserCollectionName = await this.getEncryptedDefaultUserCollectionName(orgKey); await this.organizationUserApiService.postOrganizationUserConfirm( options.organizationId, id, diff --git a/apps/cli/src/base-program.ts b/apps/cli/src/base-program.ts index 71c3830b4cc..2ce0d425007 100644 --- a/apps/cli/src/base-program.ts +++ b/apps/cli/src/base-program.ts @@ -172,9 +172,7 @@ export abstract class BaseProgram { } else { const command = new UnlockCommand( this.serviceContainer.accountService, - this.serviceContainer.masterPasswordService, this.serviceContainer.keyService, - this.serviceContainer.userVerificationService, this.serviceContainer.cryptoFunctionService, this.serviceContainer.logService, this.serviceContainer.keyConnectorService, @@ -184,7 +182,6 @@ export abstract class BaseProgram { this.serviceContainer.i18nService, this.serviceContainer.encryptedMigrator, this.serviceContainer.masterPasswordUnlockService, - this.serviceContainer.configService, ); const response = await command.run(null, null); if (!response.success) { diff --git a/apps/cli/src/key-management/commands/unlock.command.spec.ts b/apps/cli/src/key-management/commands/unlock.command.spec.ts index 50ef414ec37..a722469f7bb 100644 --- a/apps/cli/src/key-management/commands/unlock.command.spec.ts +++ b/apps/cli/src/key-management/commands/unlock.command.spec.ts @@ -3,21 +3,16 @@ import { of } from "rxjs"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; -import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { MasterKey, UserKey } from "@bitwarden/common/types/key"; +import { UserKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; import { ConsoleLogService } from "@bitwarden/logging"; import { UserId } from "@bitwarden/user-core"; @@ -32,9 +27,7 @@ describe("UnlockCommand", () => { let command: UnlockCommand; const accountService = mock(); - const masterPasswordService = mock(); const keyService = mock(); - const userVerificationService = mock(); const cryptoFunctionService = mock(); const logService = mock(); const keyConnectorService = mock(); @@ -44,7 +37,6 @@ describe("UnlockCommand", () => { const i18nService = mock(); const encryptedMigrator = mock(); const masterPasswordUnlockService = mock(); - const configService = mock(); const mockMasterPassword = "testExample"; const activeAccount: Account = { @@ -73,9 +65,6 @@ describe("UnlockCommand", () => { ); expectedSuccessMessage.raw = b64sessionKey; - // Legacy test data - const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey; - beforeEach(async () => { jest.clearAllMocks(); @@ -86,9 +75,7 @@ describe("UnlockCommand", () => { command = new UnlockCommand( accountService, - masterPasswordService, keyService, - userVerificationService, cryptoFunctionService, logService, keyConnectorService, @@ -98,7 +85,6 @@ describe("UnlockCommand", () => { i18nService, encryptedMigrator, masterPasswordUnlockService, - configService, ); }); @@ -133,116 +119,46 @@ describe("UnlockCommand", () => { }, ); - describe("UnlockWithMasterPasswordUnlockData feature flag enabled", () => { - beforeEach(() => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - }); + it("calls masterPasswordUnlockService successfully", async () => { + masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); - it("calls masterPasswordUnlockService successfully", async () => { - masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); + const response = await command.run(mockMasterPassword, {}); - const response = await command.run(mockMasterPassword, {}); - - expect(response).not.toBeNull(); - expect(response.success).toEqual(true); - expect(response.data).toEqual(expectedSuccessMessage); - expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( - mockMasterPassword, - activeAccount.id, - ); - expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); - }); - - it("returns error response if unlockWithMasterPassword fails", async () => { - masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue( - new Error("Unlock failed"), - ); - - const response = await command.run(mockMasterPassword, {}); - - expect(response).not.toBeNull(); - expect(response.success).toEqual(false); - expect(response.message).toEqual("Unlock failed"); - expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( - mockMasterPassword, - activeAccount.id, - ); - expect(keyService.setUserKey).not.toHaveBeenCalled(); - }); + expect(response).not.toBeNull(); + expect(response.success).toEqual(true); + expect(response.data).toEqual(expectedSuccessMessage); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); }); - describe("unlock with feature flag off", () => { - beforeEach(() => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - }); + it("returns error response if unlockWithMasterPassword fails", async () => { + masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue( + new Error("Unlock failed"), + ); - it("calls decryptUserKeyWithMasterKey successfully", async () => { - userVerificationService.verifyUserByMasterPassword.mockResolvedValue({ - masterKey: mockMasterKey, - } as MasterPasswordVerificationResponse); - masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey); + const response = await command.run(mockMasterPassword, {}); - const response = await command.run(mockMasterPassword, {}); - - expect(response).not.toBeNull(); - expect(response.success).toEqual(true); - expect(response.data).toEqual(expectedSuccessMessage); - expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( - { - type: VerificationType.MasterPassword, - secret: mockMasterPassword, - }, - activeAccount.id, - activeAccount.email, - ); - expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( - mockMasterKey, - activeAccount.id, - ); - expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); - }); - - it("returns error response when verifyUserByMasterPassword throws", async () => { - userVerificationService.verifyUserByMasterPassword.mockRejectedValue( - new Error("Verification failed"), - ); - - const response = await command.run(mockMasterPassword, {}); - - expect(response).not.toBeNull(); - expect(response.success).toEqual(false); - expect(response.message).toEqual("Verification failed"); - expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( - { - type: VerificationType.MasterPassword, - secret: mockMasterPassword, - }, - activeAccount.id, - activeAccount.email, - ); - expect(masterPasswordService.decryptUserKeyWithMasterKey).not.toHaveBeenCalled(); - expect(keyService.setUserKey).not.toHaveBeenCalled(); - }); + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response.message).toEqual("Unlock failed"); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + expect(keyService.setUserKey).not.toHaveBeenCalled(); }); describe("calls convertToKeyConnectorCommand if required", () => { let convertToKeyConnectorSpy: jest.SpyInstance; beforeEach(() => { keyConnectorService.convertAccountRequired$ = of(true); - - // Feature flag on masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); - - // Feature flag off - userVerificationService.verifyUserByMasterPassword.mockResolvedValue({ - masterKey: mockMasterKey, - } as MasterPasswordVerificationResponse); - masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey); }); - test.each([true, false])("returns failure when feature flag is %s", async (flagValue) => { - configService.getFeatureFlag$.mockReturnValue(of(flagValue)); - + it("returns error on failure", async () => { // Mock the ConvertToKeyConnectorCommand const mockRun = jest.fn().mockResolvedValue({ success: false, message: "convert failed" }); convertToKeyConnectorSpy = jest @@ -257,67 +173,32 @@ describe("UnlockCommand", () => { expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); expect(convertToKeyConnectorSpy).toHaveBeenCalled(); - if (flagValue === true) { - expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( - mockMasterPassword, - activeAccount.id, - ); - } else { - expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( - { - type: VerificationType.MasterPassword, - secret: mockMasterPassword, - }, - activeAccount.id, - activeAccount.email, - ); - expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( - mockMasterKey, - activeAccount.id, - ); - } + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); }); - test.each([true, false])( - "returns expected success when feature flag is %s", - async (flagValue) => { - configService.getFeatureFlag$.mockReturnValue(of(flagValue)); + it("returns success on successful conversion", async () => { + // Mock the ConvertToKeyConnectorCommand + const mockRun = jest.fn().mockResolvedValue({ success: true }); + const convertToKeyConnectorSpy = jest + .spyOn(ConvertToKeyConnectorCommand.prototype, "run") + .mockImplementation(mockRun); - // Mock the ConvertToKeyConnectorCommand - const mockRun = jest.fn().mockResolvedValue({ success: true }); - const convertToKeyConnectorSpy = jest - .spyOn(ConvertToKeyConnectorCommand.prototype, "run") - .mockImplementation(mockRun); + const response = await command.run(mockMasterPassword, {}); - const response = await command.run(mockMasterPassword, {}); + expect(response).not.toBeNull(); + expect(response.success).toEqual(true); + expect(response.data).toEqual(expectedSuccessMessage); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); + expect(convertToKeyConnectorSpy).toHaveBeenCalled(); - expect(response).not.toBeNull(); - expect(response.success).toEqual(true); - expect(response.data).toEqual(expectedSuccessMessage); - expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id); - expect(convertToKeyConnectorSpy).toHaveBeenCalled(); - - if (flagValue === true) { - expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( - mockMasterPassword, - activeAccount.id, - ); - } else { - expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( - { - type: VerificationType.MasterPassword, - secret: mockMasterPassword, - }, - activeAccount.id, - activeAccount.email, - ); - expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( - mockMasterKey, - activeAccount.id, - ); - } - }, - ); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + }); }); }); }); diff --git a/apps/cli/src/key-management/commands/unlock.command.ts b/apps/cli/src/key-management/commands/unlock.command.ts index c88d9ae1cc4..5f82b721d07 100644 --- a/apps/cli/src/key-management/commands/unlock.command.ts +++ b/apps/cli/src/key-management/commands/unlock.command.ts @@ -4,20 +4,13 @@ import { firstValueFrom } from "rxjs"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; -import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; -import { MasterKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; import { Response } from "../../models/response"; @@ -29,9 +22,7 @@ import { ConvertToKeyConnectorCommand } from "../convert-to-key-connector.comman export class UnlockCommand { constructor( private accountService: AccountService, - private masterPasswordService: InternalMasterPasswordServiceAbstraction, private keyService: KeyService, - private userVerificationService: UserVerificationService, private cryptoFunctionService: CryptoFunctionService, private logService: ConsoleLogService, private keyConnectorService: KeyConnectorService, @@ -41,7 +32,6 @@ export class UnlockCommand { private i18nService: I18nService, private encryptedMigrator: EncryptedMigrator, private masterPasswordUnlockService: MasterPasswordUnlockService, - private configService: ConfigService, ) {} async run(password: string, cmdOptions: Record) { @@ -61,46 +51,15 @@ export class UnlockCommand { } const userId = activeAccount.id; - if ( - await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.UnlockWithMasterPasswordUnlockData), - ) - ) { - try { - const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword( - password, - userId, - ); - - await this.keyService.setUserKey(userKey, userId); - } catch (e) { - return Response.error(e.message); - } - } else { - const email = activeAccount.email; - const verification = { - type: VerificationType.MasterPassword, - secret: password, - } as MasterPasswordVerification; - - let masterKey: MasterKey; - try { - const response = await this.userVerificationService.verifyUserByMasterPassword( - verification, - userId, - email, - ); - masterKey = response.masterKey; - } catch (e) { - // verification failure throws - return Response.error(e.message); - } - - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( - masterKey, + try { + const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword( + password, userId, ); + await this.keyService.setUserKey(userKey, userId); + } catch (e) { + return Response.error(e.message); } if (await firstValueFrom(this.keyConnectorService.convertAccountRequired$)) { diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index e8f5e6acd9a..e0385534cb7 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -147,7 +147,6 @@ export class OssServeConfigurator { this.serviceContainer.encryptService, this.serviceContainer.organizationUserApiService, this.serviceContainer.accountService, - this.serviceContainer.configService, this.serviceContainer.i18nService, ); this.restoreCommand = new RestoreCommand( @@ -167,9 +166,7 @@ export class OssServeConfigurator { ); this.unlockCommand = new UnlockCommand( this.serviceContainer.accountService, - this.serviceContainer.masterPasswordService, this.serviceContainer.keyService, - this.serviceContainer.userVerificationService, this.serviceContainer.cryptoFunctionService, this.serviceContainer.logService, this.serviceContainer.keyConnectorService, @@ -179,7 +176,6 @@ export class OssServeConfigurator { this.serviceContainer.i18nService, this.serviceContainer.encryptedMigrator, this.serviceContainer.masterPasswordUnlockService, - this.serviceContainer.configService, ); this.sendCreateCommand = new SendCreateCommand( diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 870d743095d..7856fc3588c 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -303,9 +303,7 @@ export class Program extends BaseProgram { await this.exitIfNotAuthed(); const command = new UnlockCommand( this.serviceContainer.accountService, - this.serviceContainer.masterPasswordService, this.serviceContainer.keyService, - this.serviceContainer.userVerificationService, this.serviceContainer.cryptoFunctionService, this.serviceContainer.logService, this.serviceContainer.keyConnectorService, @@ -315,7 +313,6 @@ export class Program extends BaseProgram { this.serviceContainer.i18nService, this.serviceContainer.encryptedMigrator, this.serviceContainer.masterPasswordUnlockService, - this.serviceContainer.configService, ); const response = await command.run(password, cmd); this.processResponse(response); diff --git a/apps/cli/src/vault.program.ts b/apps/cli/src/vault.program.ts index 21f87feab00..3e08038fe64 100644 --- a/apps/cli/src/vault.program.ts +++ b/apps/cli/src/vault.program.ts @@ -494,7 +494,6 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.encryptService, this.serviceContainer.organizationUserApiService, this.serviceContainer.accountService, - this.serviceContainer.configService, this.serviceContainer.i18nService, ); const response = await command.run(object, id, cmd); diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index f5e5cf7ee18..d4a5ccf7aca 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -533,9 +533,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.49" +version = "1.2.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", "shlex", @@ -1180,9 +1180,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" [[package]] name = "fixedbitset" @@ -1671,7 +1671,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a793df0d7afeac54f95b471d3af7f0d4fb975699f972341a4b76988d49cdf0c" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.53.3", ] [[package]] diff --git a/apps/desktop/desktop_native/autotype/src/lib.rs b/apps/desktop/desktop_native/autotype/src/lib.rs index 4b9e65180e6..917f0f797b6 100644 --- a/apps/desktop/desktop_native/autotype/src/lib.rs +++ b/apps/desktop/desktop_native/autotype/src/lib.rs @@ -1,5 +1,11 @@ use anyhow::Result; +#[cfg(target_os = "windows")] +mod modifier_keys; + +#[cfg(target_os = "windows")] +pub(crate) use modifier_keys::*; + #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] #[cfg_attr(target_os = "windows", path = "windows/mod.rs")] diff --git a/apps/desktop/desktop_native/autotype/src/modifier_keys.rs b/apps/desktop/desktop_native/autotype/src/modifier_keys.rs new file mode 100644 index 00000000000..c451a3b25e4 --- /dev/null +++ b/apps/desktop/desktop_native/autotype/src/modifier_keys.rs @@ -0,0 +1,45 @@ +// Electron modifier keys +// +pub(crate) const CONTROL_KEY_STR: &str = "Control"; +pub(crate) const ALT_KEY_STR: &str = "Alt"; +pub(crate) const SUPER_KEY_STR: &str = "Super"; + +// numeric values for modifier keys +pub(crate) const CONTROL_KEY: u16 = 0x11; +pub(crate) const ALT_KEY: u16 = 0x12; +pub(crate) const SUPER_KEY: u16 = 0x5B; + +/// A mapping of to +static MODIFIER_KEYS: [(&str, u16); 3] = [ + (CONTROL_KEY_STR, CONTROL_KEY), + (ALT_KEY_STR, ALT_KEY), + (SUPER_KEY_STR, SUPER_KEY), +]; + +/// Provides a mapping of the valid modifier keys' electron +/// string representation to the numeric representation. +pub(crate) fn get_numeric_modifier_key(modifier: &str) -> Option { + for (modifier_str, modifier_num) in MODIFIER_KEYS { + if modifier_str == modifier { + return Some(modifier_num); + } + } + None +} + +#[cfg(test)] +mod test { + use super::get_numeric_modifier_key; + + #[test] + fn valid_modifier_keys() { + assert_eq!(get_numeric_modifier_key("Control").unwrap(), 0x11); + assert_eq!(get_numeric_modifier_key("Alt").unwrap(), 0x12); + assert_eq!(get_numeric_modifier_key("Super").unwrap(), 0x5B); + } + + #[test] + fn does_not_contain_invalid_modifier_keys() { + assert!(get_numeric_modifier_key("Shift").is_none()); + } +} diff --git a/apps/desktop/desktop_native/autotype/src/windows/mod.rs b/apps/desktop/desktop_native/autotype/src/windows/mod.rs index 9cd9bc0cbe5..ed985749303 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/mod.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/mod.rs @@ -44,7 +44,6 @@ pub fn get_foreground_window_title() -> Result { /// - Control /// - Alt /// - Super -/// - Shift /// - \[a-z\]\[A-Z\] struct KeyboardShortcutInput(INPUT); diff --git a/apps/desktop/desktop_native/autotype/src/windows/type_input.rs b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs index b62dd7290d1..f7879e676bf 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/type_input.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs @@ -6,11 +6,7 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{ }; use super::{ErrorOperations, KeyboardShortcutInput, Win32ErrorOperations}; - -const SHIFT_KEY_STR: &str = "Shift"; -const CONTROL_KEY_STR: &str = "Control"; -const ALT_KEY_STR: &str = "Alt"; -const LEFT_WINDOWS_KEY_STR: &str = "Super"; +use crate::get_numeric_modifier_key; const IS_VIRTUAL_KEY: bool = true; const IS_REAL_KEY: bool = false; @@ -88,22 +84,19 @@ impl TryFrom<&str> for KeyboardShortcutInput { type Error = anyhow::Error; fn try_from(key: &str) -> std::result::Result { - const SHIFT_KEY: u16 = 0x10; - const CONTROL_KEY: u16 = 0x11; - const ALT_KEY: u16 = 0x12; - const LEFT_WINDOWS_KEY: u16 = 0x5B; - + // not modifier key + if key.len() == 1 { + let input = build_unicode_input(InputKeyPress::Up, get_alphabetic_hotkey(key)?); + return Ok(KeyboardShortcutInput(input)); + } // the modifier keys are using the Up keypress variant because the user has already // pressed those keys in order to trigger the feature. - let input = match key { - SHIFT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, SHIFT_KEY), - CONTROL_KEY_STR => build_virtual_key_input(InputKeyPress::Up, CONTROL_KEY), - ALT_KEY_STR => build_virtual_key_input(InputKeyPress::Up, ALT_KEY), - LEFT_WINDOWS_KEY_STR => build_virtual_key_input(InputKeyPress::Up, LEFT_WINDOWS_KEY), - _ => build_unicode_input(InputKeyPress::Up, get_alphabetic_hotkey(key)?), - }; - - Ok(KeyboardShortcutInput(input)) + if let Some(numeric_modifier_key) = get_numeric_modifier_key(key) { + let input = build_virtual_key_input(InputKeyPress::Up, numeric_modifier_key); + Ok(KeyboardShortcutInput(input)) + } else { + Err(anyhow!("Unsupported modifier key: {key}")) + } } } @@ -278,7 +271,7 @@ mod tests { #[test] #[serial] fn keyboard_shortcut_conversion_succeeds() { - let keyboard_shortcut = [CONTROL_KEY_STR, SHIFT_KEY_STR, "B"]; + let keyboard_shortcut = ["Control", "Alt", "B"]; let _: Vec = keyboard_shortcut .iter() .map(|s| KeyboardShortcutInput::try_from(*s)) @@ -290,7 +283,19 @@ mod tests { #[serial] #[should_panic = "Letter is not ASCII Alphabetic ([a-z][A-Z]): '1'"] fn keyboard_shortcut_conversion_fails_invalid_key() { - let keyboard_shortcut = [CONTROL_KEY_STR, SHIFT_KEY_STR, "1"]; + let keyboard_shortcut = ["Control", "Alt", "1"]; + let _: Vec = keyboard_shortcut + .iter() + .map(|s| KeyboardShortcutInput::try_from(*s)) + .try_collect() + .unwrap(); + } + + #[test] + #[serial] + #[should_panic(expected = "Unsupported modifier key: Shift")] + fn keyboard_shortcut_conversion_fails_with_shift() { + let keyboard_shortcut = ["Control", "Shift", "B"]; let _: Vec = keyboard_shortcut .iter() .map(|s| KeyboardShortcutInput::try_from(*s)) diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index 16cf778b575..8ba64618ffa 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -226,7 +226,7 @@ impl BitwardenDesktopAgent { keystore.0.write().expect("RwLock is not poisoned").clear(); self.needs_unlock - .store(false, std::sync::atomic::Ordering::Relaxed); + .store(true, std::sync::atomic::Ordering::Relaxed); for (key, name, cipher_id) in new_keys.iter() { match parse_key_safe(key) { @@ -307,87 +307,3 @@ fn parse_key_safe(pem: &str) -> Result Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))), } } - -#[cfg(test)] -mod tests { - use super::*; - - fn create_test_agent() -> ( - BitwardenDesktopAgent, - tokio::sync::mpsc::Receiver, - tokio::sync::broadcast::Sender<(u32, bool)>, - ) { - let (tx, rx) = tokio::sync::mpsc::channel(10); - let (response_tx, response_rx) = tokio::sync::broadcast::channel(10); - let agent = BitwardenDesktopAgent::new(tx, Arc::new(Mutex::new(response_rx))); - (agent, rx, response_tx) - } - - const TEST_ED25519_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACCWETEIh/JX+ZaK0Xlg5xZ9QIfjiKD2Qs57PjhRY45trwAAAIhqmvSbapr0 -mwAAAAtzc2gtZWQyNTUxOQAAACCWETEIh/JX+ZaK0Xlg5xZ9QIfjiKD2Qs57PjhRY45trw -AAAEAHVflTgR/OEl8mg9UEKcO7SeB0FH4AiaUurhVfBWT4eZYRMQiH8lf5lorReWDnFn1A -h+OIoPZCzns+OFFjjm2vAAAAAAECAwQF ------END OPENSSH PRIVATE KEY-----"; - - #[tokio::test] - async fn test_needs_unlock_initial_state() { - let (agent, _rx, _response_tx) = create_test_agent(); - - // Initially, needs_unlock should be true - assert!(agent - .needs_unlock - .load(std::sync::atomic::Ordering::Relaxed)); - } - - #[tokio::test] - async fn test_needs_unlock_after_set_keys() { - let (mut agent, _rx, _response_tx) = create_test_agent(); - agent - .is_running - .store(true, std::sync::atomic::Ordering::Relaxed); - - // Set keys should set needs_unlock to false - let keys = vec![( - TEST_ED25519_KEY.to_string(), - "test_key".to_string(), - "cipher_id".to_string(), - )]; - - agent.set_keys(keys).unwrap(); - - assert!(!agent - .needs_unlock - .load(std::sync::atomic::Ordering::Relaxed)); - } - - #[tokio::test] - async fn test_needs_unlock_after_clear_keys() { - let (mut agent, _rx, _response_tx) = create_test_agent(); - agent - .is_running - .store(true, std::sync::atomic::Ordering::Relaxed); - - // Set keys first - let keys = vec![( - TEST_ED25519_KEY.to_string(), - "test_key".to_string(), - "cipher_id".to_string(), - )]; - agent.set_keys(keys).unwrap(); - - // Verify needs_unlock is false - assert!(!agent - .needs_unlock - .load(std::sync::atomic::Ordering::Relaxed)); - - // Clear keys should set needs_unlock back to true - agent.clear_keys().unwrap(); - - // Verify needs_unlock is true - assert!(agent - .needs_unlock - .load(std::sync::atomic::Ordering::Relaxed)); - } -} diff --git a/apps/desktop/desktop_native/deny.toml b/apps/desktop/desktop_native/deny.toml index 7d7a126f694..66b80e9984c 100644 --- a/apps/desktop/desktop_native/deny.toml +++ b/apps/desktop/desktop_native/deny.toml @@ -1,9 +1,10 @@ # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html [advisories] +# Allow unmaintained crates in transient deps but not direct +unmaintained = "workspace" ignore = [ # Vulnerability in `rsa` crate: https://rustsec.org/advisories/RUSTSEC-2023-0071.html { id = "RUSTSEC-2023-0071", reason = "There is no fix available yet." }, - { id = "RUSTSEC-2024-0436", reason = "paste crate is unmaintained."} ] # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html diff --git a/apps/desktop/desktop_native/objc/Cargo.toml b/apps/desktop/desktop_native/objc/Cargo.toml index dd808537c28..1edf30ac996 100644 --- a/apps/desktop/desktop_native/objc/Cargo.toml +++ b/apps/desktop/desktop_native/objc/Cargo.toml @@ -14,7 +14,7 @@ tokio = { workspace = true } tracing = { workspace = true } [target.'cfg(target_os = "macos")'.build-dependencies] -cc = "=1.2.49" +cc = "=1.2.51" glob = "=0.3.3" [lints] diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 97ab8585a69..17322c42a84 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -40,8 +40,8 @@ "clean:dist": "rimraf ./dist", "pack:dir": "npm run clean:dist && electron-builder --dir -p never", "pack:lin:flatpak": "flatpak-builder --repo=../../.flatpak-repo ../../.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ../../.flatpak-repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop", - "pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && tar -czvf ./dist/bitwarden_desktop_x64.tar.gz -C ./dist/linux-unpacked/ .", - "pack:lin:arm64": "npm run clean:dist && electron-builder --linux --arm64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .", + "pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && cp ./resources/com.bitwarden.desktop.desktop ./dist/linux-unpacked/resources && cp -r ./resources/icons ./dist/linux-unpacked/resources && tar -czvf ./dist/bitwarden_desktop_x64.tar.gz -C ./dist/linux-unpacked/ .", + "pack:lin:arm64": "npm run clean:dist && electron-builder --linux --arm64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && cp ./resources/com.bitwarden.desktop.desktop ./dist/linux-arm64-unpacked/resources && cp -r ./resources/icons ./dist/linux-arm64-unpacked/resources && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .", "pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never", "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", diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index d518ac29aa4..bffa06d2654 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -188,7 +188,7 @@ describe("SettingsComponent", () => { pinServiceAbstraction.isPinSet.mockResolvedValue(false); policyService.policiesByType$.mockReturnValue(of([null])); desktopAutotypeService.autotypeEnabledUserSetting$ = of(false); - desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Shift", "B"]); + desktopAutotypeService.autotypeKeyboardShortcut$ = of(["Control", "Alt", "B"]); billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); configService.getFeatureFlag$.mockReturnValue(of(false)); }); diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 6077afa8c12..f75f6ccdc20 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -361,6 +361,7 @@ const routes: Routes = [ { path: "new-sends", component: SendV2Component, + data: { pageTitle: { key: "send" } } satisfies RouteDataProperties, }, ], }, diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index d75702ee8b8..d1919c77bb5 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -31,6 +31,7 @@ import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; import { + AuthRequestServiceAbstraction, DESKTOP_SSO_CALLBACK, LockService, LogoutReason, @@ -40,11 +41,13 @@ import { EventUploadService } from "@bitwarden/common/abstractions/event/event-u import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; @@ -151,6 +154,8 @@ export class AppComponent implements OnInit, OnDestroy { private isIdle = false; private activeUserId: UserId = null; private activeSimpleDialog: DialogRef = null; + private processingPendingAuthRequests = false; + private shouldRerunAuthRequestProcessing = false; private destroy$ = new Subject(); @@ -200,6 +205,9 @@ export class AppComponent implements OnInit, OnDestroy { private desktopAutotypeDefaultSettingPolicy: DesktopAutotypeDefaultSettingPolicy, private readonly lockService: LockService, private premiumUpgradePromptService: PremiumUpgradePromptService, + private pendingAuthRequestsState: PendingAuthRequestsStateService, + private authRequestService: AuthRequestServiceAbstraction, + private authRequestAnsweringService: AuthRequestAnsweringService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); @@ -212,6 +220,8 @@ export class AppComponent implements OnInit, OnDestroy { this.activeUserId = account?.id; }); + this.authRequestAnsweringService.setupUnlockListenersForProcessingAuthRequests(this.destroy$); + this.ngZone.runOutsideAngular(() => { setTimeout(async () => { await this.updateAppMenu(); @@ -499,13 +509,31 @@ export class AppComponent implements OnInit, OnDestroy { await this.checkForSystemTimeout(VaultTimeoutStringType.OnIdle); break; case "openLoginApproval": - if (message.notificationId != null) { - this.dialogService.closeAll(); - const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, { - notificationId: message.notificationId, - }); - await firstValueFrom(dialogRef.closed); + if (this.processingPendingAuthRequests) { + // If an "openLoginApproval" message is received while we are currently processing other + // auth requests, then set a flag so we remember to process that new auth request + this.shouldRerunAuthRequestProcessing = true; + return; } + + /** + * This do/while loop allows us to: + * - a) call processPendingAuthRequests() once on "openLoginApproval" + * - b) remember to re-call processPendingAuthRequests() if another "openLoginApproval" was + * received while we were processing the original auth requests + */ + do { + this.shouldRerunAuthRequestProcessing = false; + + try { + await this.processPendingAuthRequests(); + } catch (error) { + this.logService.error(`Error processing pending auth requests: ${error}`); + this.shouldRerunAuthRequestProcessing = false; // Reset flag to prevent infinite loop on persistent errors + } + // If an "openLoginApproval" message was received while processPendingAuthRequests() was running, then + // shouldRerunAuthRequestProcessing will have been set to true + } while (this.shouldRerunAuthRequestProcessing); break; case "redrawMenu": await this.updateAppMenu(); @@ -887,4 +915,39 @@ export class AppComponent implements OnInit, OnDestroy { DeleteAccountComponent.open(this.dialogService); } + + private async processPendingAuthRequests() { + this.processingPendingAuthRequests = true; + + try { + // Always query server for all pending requests and open a dialog for each + const pendingList = await firstValueFrom(this.authRequestService.getPendingAuthRequests$()); + + if (Array.isArray(pendingList) && pendingList.length > 0) { + const respondedIds = new Set(); + + for (const req of pendingList) { + if (req?.id == null) { + continue; + } + + const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, { + notificationId: req.id, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if (result !== undefined && typeof result === "boolean") { + respondedIds.add(req.id); + + if (respondedIds.size === pendingList.length && this.activeUserId != null) { + await this.pendingAuthRequestsState.clear(this.activeUserId); + } + } + } + } + } finally { + this.processingPendingAuthRequests = false; + } + } } diff --git a/apps/desktop/src/app/layout/desktop-layout.component.html b/apps/desktop/src/app/layout/desktop-layout.component.html index 1717b29acd1..7e101ae1b6e 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.html +++ b/apps/desktop/src/app/layout/desktop-layout.component.html @@ -1,9 +1,13 @@ - + - - + + + + + + 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 253444232e5..2fb49e723ef 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts @@ -5,7 +5,7 @@ import { mock } from "jest-mock-extended"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; -import { NavigationModule } from "@bitwarden/components"; +import { DialogService, NavigationModule } from "@bitwarden/components"; import { GlobalStateProvider } from "@bitwarden/state"; import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component"; @@ -52,6 +52,10 @@ describe("DesktopLayoutComponent", () => { provide: GlobalStateProvider, useValue: fakeGlobalStateProvider, }, + { + provide: DialogService, + useValue: mock(), + }, ], }) .overrideComponent(DesktopLayoutComponent, { diff --git a/apps/desktop/src/app/layout/desktop-layout.component.ts b/apps/desktop/src/app/layout/desktop-layout.component.ts index 0ee7065fba8..85339bc06c9 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.ts @@ -1,10 +1,13 @@ -import { Component } from "@angular/core"; +import { Component, inject } from "@angular/core"; import { RouterModule } from "@angular/router"; import { PasswordManagerLogo } from "@bitwarden/assets/svg"; -import { LayoutComponent, NavigationModule } from "@bitwarden/components"; +import { DialogService, LayoutComponent, NavigationModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +import { ExportDesktopComponent } from "../tools/export/export-desktop.component"; +import { CredentialGeneratorComponent } from "../tools/generator/credential-generator.component"; +import { ImportDesktopComponent } from "../tools/import/import-desktop.component"; import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component"; import { DesktopSideNavComponent } from "./desktop-side-nav.component"; @@ -24,5 +27,19 @@ import { DesktopSideNavComponent } from "./desktop-side-nav.component"; templateUrl: "./desktop-layout.component.html", }) export class DesktopLayoutComponent { + private dialogService = inject(DialogService); + protected readonly logo = PasswordManagerLogo; + + protected openGenerator() { + this.dialogService.open(CredentialGeneratorComponent); + } + + protected openImport() { + this.dialogService.open(ImportDesktopComponent); + } + + protected openExport() { + this.dialogService.open(ExportDesktopComponent); + } } diff --git a/apps/desktop/src/app/layout/header/desktop-header.component.html b/apps/desktop/src/app/layout/header/desktop-header.component.html new file mode 100644 index 00000000000..efee5e21d9b --- /dev/null +++ b/apps/desktop/src/app/layout/header/desktop-header.component.html @@ -0,0 +1,21 @@ +
+ + + + + + + + + + + + + + + + + + + +
diff --git a/apps/desktop/src/app/layout/header/desktop-header.component.spec.ts b/apps/desktop/src/app/layout/header/desktop-header.component.spec.ts new file mode 100644 index 00000000000..8d3db198887 --- /dev/null +++ b/apps/desktop/src/app/layout/header/desktop-header.component.spec.ts @@ -0,0 +1,122 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { HeaderComponent } from "@bitwarden/components"; + +import { DesktopHeaderComponent } from "./desktop-header.component"; + +describe("DesktopHeaderComponent", () => { + let component: DesktopHeaderComponent; + let fixture: ComponentFixture; + let mockI18nService: ReturnType>; + let mockActivatedRoute: { data: any }; + + beforeEach(async () => { + mockI18nService = mock(); + mockI18nService.t.mockImplementation((key: string) => `translated_${key}`); + + mockActivatedRoute = { + data: of({}), + }; + + await TestBed.configureTestingModule({ + imports: [DesktopHeaderComponent, HeaderComponent], + providers: [ + { + provide: I18nService, + useValue: mockI18nService, + }, + { + provide: ActivatedRoute, + useValue: mockActivatedRoute, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DesktopHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("creates component", () => { + expect(component).toBeTruthy(); + }); + + it("renders bit-header component", () => { + const compiled = fixture.nativeElement; + const headerElement = compiled.querySelector("bit-header"); + + expect(headerElement).toBeTruthy(); + }); + + describe("title resolution", () => { + it("uses title input when provided", () => { + fixture.componentRef.setInput("title", "Direct Title"); + fixture.detectChanges(); + + expect(component["resolvedTitle"]()).toBe("Direct Title"); + }); + + it("uses route data titleId when no direct title provided", () => { + mockActivatedRoute.data = of({ + pageTitle: { key: "sends" }, + }); + + fixture = TestBed.createComponent(DesktopHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(mockI18nService.t).toHaveBeenCalledWith("sends"); + expect(component["resolvedTitle"]()).toBe("translated_sends"); + }); + + it("returns empty string when no title or route data provided", () => { + mockActivatedRoute.data = of({}); + + fixture = TestBed.createComponent(DesktopHeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component["resolvedTitle"]()).toBe(""); + }); + + it("prioritizes direct title over route data", () => { + mockActivatedRoute.data = of({ + pageTitle: { key: "sends" }, + }); + + fixture = TestBed.createComponent(DesktopHeaderComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("title", "Override Title"); + fixture.detectChanges(); + + expect(component["resolvedTitle"]()).toBe("Override Title"); + }); + }); + + describe("icon input", () => { + it("accepts icon input", () => { + fixture.componentRef.setInput("icon", "bwi-send"); + fixture.detectChanges(); + + expect(component.icon()).toBe("bwi-send"); + }); + + it("defaults to undefined when no icon provided", () => { + expect(component.icon()).toBeUndefined(); + }); + }); + + describe("content projection", () => { + it("wraps bit-header component for slot pass-through", () => { + const compiled = fixture.nativeElement; + const bitHeader = compiled.querySelector("bit-header"); + + // Verify bit-header exists and can receive projected content + expect(bitHeader).toBeTruthy(); + }); + }); +}); diff --git a/apps/desktop/src/app/layout/header/desktop-header.component.ts b/apps/desktop/src/app/layout/header/desktop-header.component.ts new file mode 100644 index 00000000000..5a837f1ff5a --- /dev/null +++ b/apps/desktop/src/app/layout/header/desktop-header.component.ts @@ -0,0 +1,47 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { ActivatedRoute } from "@angular/router"; +import { map } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { HeaderComponent, BannerModule } from "@bitwarden/components"; + +@Component({ + selector: "app-header", + templateUrl: "./desktop-header.component.html", + imports: [BannerModule, HeaderComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DesktopHeaderComponent { + private route = inject(ActivatedRoute); + private i18nService = inject(I18nService); + + /** + * Title to display in header (takes precedence over route data) + */ + readonly title = input(); + + /** + * Icon to show before the title + */ + readonly icon = input(); + + private readonly routeData = toSignal( + this.route.data.pipe( + map((params) => ({ + titleId: params["pageTitle"]?.["key"] as string | undefined, + })), + ), + { initialValue: { titleId: undefined } }, + ); + + protected readonly resolvedTitle = computed(() => { + const directTitle = this.title(); + if (directTitle) { + return directTitle; + } + + const titleId = this.routeData().titleId; + return titleId ? this.i18nService.t(titleId) : ""; + }); +} diff --git a/apps/desktop/src/app/layout/header/index.ts b/apps/desktop/src/app/layout/header/index.ts new file mode 100644 index 00000000000..793d90f81e5 --- /dev/null +++ b/apps/desktop/src/app/layout/header/index.ts @@ -0,0 +1 @@ +export { DesktopHeaderComponent } from "./desktop-header.component"; diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 382b680efbc..752c09e2e92 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -5,7 +5,6 @@ import { Router } from "@angular/router"; import { Subject, merge } from "rxjs"; import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; -import { LoginApprovalDialogComponentServiceAbstraction } from "@bitwarden/angular/auth/login-approval"; 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 { @@ -45,6 +44,7 @@ import { AccountService, AccountService as AccountServiceAbstraction, } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { AuthService, AuthService as AuthServiceAbstraction, @@ -52,6 +52,7 @@ import { import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { ClientType } from "@bitwarden/common/enums"; @@ -61,7 +62,10 @@ import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { + InternalMasterPasswordServiceAbstraction, + MasterPasswordServiceAbstraction, +} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service"; import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; @@ -120,8 +124,8 @@ import { import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; -import { DesktopLoginApprovalDialogComponentService } from "../../auth/login/desktop-login-approval-dialog-component.service"; import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service"; +import { DesktopAuthRequestAnsweringService } from "../../auth/services/auth-request-answering/desktop-auth-request-answering.service"; import { DesktopTwoFactorAuthDuoComponentService } from "../../auth/services/desktop-two-factor-auth-duo-component.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service"; @@ -469,11 +473,6 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultSsoComponentService, deps: [], }), - safeProvider({ - provide: LoginApprovalDialogComponentServiceAbstraction, - useClass: DesktopLoginApprovalDialogComponentService, - deps: [I18nServiceAbstraction], - }), safeProvider({ provide: SshImportPromptService, useClass: DefaultSshImportPromptService, @@ -509,6 +508,19 @@ const safeProviders: SafeProvider[] = [ useClass: SessionTimeoutSettingsComponentService, deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyServiceAbstraction], }), + safeProvider({ + provide: AuthRequestAnsweringService, + useClass: DesktopAuthRequestAnsweringService, + deps: [ + AccountServiceAbstraction, + AuthService, + MasterPasswordServiceAbstraction, + MessagingServiceAbstraction, + PendingAuthRequestsStateService, + I18nServiceAbstraction, + LogService, + ], + }), ]; @NgModule({ 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 20cac15138a..05c1332f1e7 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,110 +1,55 @@
-
-
-
- -
-
- - - -

{{ "noItemsInList" | i18n }}

-
-
- +
+ + + @if (!disableSend()) { + + } + +
+ + + +
- -

- {{ "editAutotypeShortcutDescription" | i18n }} + {{ "editAutotypeKeyboardModifiersDescription" | i18n }}

{{ "typeShortcut" | i18n }} diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts b/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts index 90aa493c596..ea394274600 100644 --- a/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.spec.ts @@ -30,11 +30,9 @@ describe("AutotypeShortcutComponent", () => { const validShortcuts = [ "Control+A", "Alt+B", - "Shift+C", "Win+D", "control+e", // case insensitive "ALT+F", - "SHIFT+G", "WIN+H", ]; @@ -46,14 +44,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should accept two modifiers with letter", () => { - const validShortcuts = [ - "Control+Alt+A", - "Control+Shift+B", - "Control+Win+C", - "Alt+Shift+D", - "Alt+Win+E", - "Shift+Win+F", - ]; + const validShortcuts = ["Control+Alt+A", "Control+Win+C", "Alt+Win+D", "Alt+Win+E"]; validShortcuts.forEach((shortcut) => { const control = createControl(shortcut); @@ -63,7 +54,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should accept modifiers in different orders", () => { - const validShortcuts = ["Alt+Control+A", "Shift+Control+B", "Win+Alt+C"]; + const validShortcuts = ["Alt+Control+A", "Win+Control+B", "Win+Alt+C"]; validShortcuts.forEach((shortcut) => { const control = createControl(shortcut); @@ -88,15 +79,14 @@ describe("AutotypeShortcutComponent", () => { const invalidShortcuts = [ "Control+1", "Alt+2", - "Shift+3", "Win+4", "Control+!", "Alt+@", - "Shift+#", + "Alt+#", "Win+$", "Control+Space", "Alt+Enter", - "Shift+Tab", + "Control+Tab", "Win+Escape", ]; @@ -111,12 +101,10 @@ describe("AutotypeShortcutComponent", () => { const invalidShortcuts = [ "Control", "Alt", - "Shift", "Win", "Control+Alt", - "Control+Shift", - "Alt+Shift", - "Control+Alt+Shift", + "Control+Win", + "Control+Alt+Win", ]; invalidShortcuts.forEach((shortcut) => { @@ -127,7 +115,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should reject shortcuts with invalid modifier names", () => { - const invalidShortcuts = ["Ctrl+A", "Command+A", "Super+A", "Meta+A", "Cmd+A", "Invalid+A"]; + const invalidShortcuts = ["Ctrl+A", "Command+A", "Meta+A", "Cmd+A", "Invalid+A"]; invalidShortcuts.forEach((shortcut) => { const control = createControl(shortcut); @@ -137,7 +125,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should reject shortcuts with multiple base keys", () => { - const invalidShortcuts = ["Control+A+B", "Alt+Ctrl+Shift"]; + const invalidShortcuts = ["Control+A+B", "Alt+Ctrl+Win"]; invalidShortcuts.forEach((shortcut) => { const control = createControl(shortcut); @@ -148,11 +136,10 @@ describe("AutotypeShortcutComponent", () => { it("should reject shortcuts with more than two modifiers", () => { const invalidShortcuts = [ - "Control+Alt+Shift+A", + "Control+Alt+Win+A", "Control+Alt+Win+B", - "Control+Shift+Win+C", - "Alt+Shift+Win+D", - "Control+Alt+Shift+Win+E", + "Control+Alt+Win+C", + "Alt+Control+Win+D", ]; invalidShortcuts.forEach((shortcut) => { @@ -221,7 +208,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should handle very long strings", () => { - const longString = "Control+Alt+Shift+Win+A".repeat(100); + const longString = "Control+Alt+Win+A".repeat(100); const control = createControl(longString); const result = validator(control); expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); @@ -230,7 +217,7 @@ describe("AutotypeShortcutComponent", () => { describe("modifier combinations", () => { it("should accept all possible single modifier combinations", () => { - const modifiers = ["Control", "Alt", "Shift", "Win"]; + const modifiers = ["Control", "Alt", "Win"]; modifiers.forEach((modifier) => { const control = createControl(`${modifier}+A`); @@ -240,14 +227,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should accept all possible two-modifier combinations", () => { - const combinations = [ - "Control+Alt+A", - "Control+Shift+A", - "Control+Win+A", - "Alt+Shift+A", - "Alt+Win+A", - "Shift+Win+A", - ]; + const combinations = ["Control+Alt+A", "Control+Win+A", "Alt+Win+A"]; combinations.forEach((shortcut) => { const control = createControl(shortcut); @@ -257,12 +237,7 @@ describe("AutotypeShortcutComponent", () => { }); it("should reject all three-modifier combinations", () => { - const combinations = [ - "Control+Alt+Shift+A", - "Control+Alt+Win+A", - "Control+Shift+Win+A", - "Alt+Shift+Win+A", - ]; + const combinations = ["Control+Alt+Win+A", "Alt+Control+Win+A", "Win+Alt+Control+A"]; combinations.forEach((shortcut) => { const control = createControl(shortcut); @@ -270,12 +245,6 @@ describe("AutotypeShortcutComponent", () => { expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); }); }); - - it("should reject all four modifiers combination", () => { - const control = createControl("Control+Alt+Shift+Win+A"); - const result = validator(control); - expect(result).toEqual({ invalidShortcut: { message: "Invalid shortcut" } }); - }); }); }); }); diff --git a/apps/desktop/src/autofill/components/autotype-shortcut.component.ts b/apps/desktop/src/autofill/components/autotype-shortcut.component.ts index 3c82d8297a1..4e1a0c2108c 100644 --- a/apps/desktop/src/autofill/components/autotype-shortcut.component.ts +++ b/apps/desktop/src/autofill/components/autotype-shortcut.component.ts @@ -77,25 +77,31 @@ export class AutotypeShortcutComponent { } } + // private buildShortcutFromEvent(event: KeyboardEvent): string | null { const hasCtrl = event.ctrlKey; const hasAlt = event.altKey; const hasShift = event.shiftKey; - const hasMeta = event.metaKey; // Windows key on Windows, Command on macOS + const hasSuper = event.metaKey; // Windows key on Windows, Command on macOS - // Require at least one modifier (Control, Alt, Shift, or Super) - if (!hasCtrl && !hasAlt && !hasShift && !hasMeta) { + // Require at least one valid modifier (Control, Alt, Super) + if (!hasCtrl && !hasAlt && !hasSuper) { return null; } const key = event.key; - // Ignore pure modifier keys themselves - if (key === "Control" || key === "Alt" || key === "Shift" || key === "Meta") { + // disallow pure modifier keys themselves + if (key === "Control" || key === "Alt" || key === "Meta") { return null; } - // Accept a single alphabetical letter as the base key + // disallow shift modifier + if (hasShift) { + return null; + } + + // require a single alphabetical letter as the base key const isAlphabetical = typeof key === "string" && /^[a-z]$/i.test(key); if (!isAlphabetical) { return null; @@ -108,10 +114,7 @@ export class AutotypeShortcutComponent { if (hasAlt) { parts.push("Alt"); } - if (hasShift) { - parts.push("Shift"); - } - if (hasMeta) { + if (hasSuper) { parts.push("Super"); } parts.push(key.toUpperCase()); @@ -129,10 +132,9 @@ export class AutotypeShortcutComponent { } // Must include exactly 1-2 modifiers and end with a single letter - // Valid examples: Ctrl+A, Shift+Z, Ctrl+Shift+X, Alt+Shift+Q + // Valid examples: Ctrl+A, Alt+B, Ctrl+Alt+X, Alt+Control+Q, Win+B, Ctrl+Win+A // Allow modifiers in any order, but only 1-2 modifiers total - const pattern = - /^(?=.*\b(Control|Alt|Shift|Win)\b)(?:Control\+|Alt\+|Shift\+|Win\+){1,2}[A-Z]$/i; + const pattern = /^(?=.*\b(Control|Alt|Win)\b)(?:Control\+|Alt\+|Win\+){1,2}[A-Z]$/i; return pattern.test(value) ? null : { invalidShortcut: { message: this.i18nService.t("invalidShortcut") } }; diff --git a/apps/desktop/src/autofill/main/main-desktop-autotype.service.spec.ts b/apps/desktop/src/autofill/main/main-desktop-autotype.service.spec.ts new file mode 100644 index 00000000000..92802d2e2cf --- /dev/null +++ b/apps/desktop/src/autofill/main/main-desktop-autotype.service.spec.ts @@ -0,0 +1,400 @@ +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ + +import { TestBed } from "@angular/core/testing"; +import { ipcMain, globalShortcut } from "electron"; + +import { autotype } from "@bitwarden/desktop-napi"; +import { LogService } from "@bitwarden/logging"; + +import { WindowMain } from "../../main/window.main"; +import { AutotypeConfig } from "../models/autotype-config"; +import { AutotypeMatchError } from "../models/autotype-errors"; +import { AutotypeVaultData } from "../models/autotype-vault-data"; +import { AUTOTYPE_IPC_CHANNELS } from "../models/ipc-channels"; +import { AutotypeKeyboardShortcut } from "../models/main-autotype-keyboard-shortcut"; + +import { MainDesktopAutotypeService } from "./main-desktop-autotype.service"; + +// Mock electron modules +jest.mock("electron", () => ({ + ipcMain: { + on: jest.fn(), + removeAllListeners: jest.fn(), + }, + globalShortcut: { + register: jest.fn(), + unregister: jest.fn(), + isRegistered: jest.fn(), + }, +})); + +// Mock desktop-napi +jest.mock("@bitwarden/desktop-napi", () => ({ + autotype: { + getForegroundWindowTitle: jest.fn(), + typeInput: jest.fn(), + }, +})); + +// Mock AutotypeKeyboardShortcut +jest.mock("../models/main-autotype-keyboard-shortcut", () => ({ + AutotypeKeyboardShortcut: jest.fn().mockImplementation(() => ({ + set: jest.fn().mockReturnValue(true), + getElectronFormat: jest.fn().mockReturnValue("Control+Alt+B"), + getArrayFormat: jest.fn().mockReturnValue(["Control", "Alt", "B"]), + })), +})); + +describe("MainDesktopAutotypeService", () => { + let service: MainDesktopAutotypeService; + let mockLogService: jest.Mocked; + let mockWindowMain: jest.Mocked; + let ipcHandlers: Map; + + beforeEach(() => { + // Track IPC handlers + ipcHandlers = new Map(); + (ipcMain.on as jest.Mock).mockImplementation((channel: string, handler: Function) => { + ipcHandlers.set(channel, handler); + }); + + // Mock LogService + mockLogService = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warning: jest.fn(), + } as any; + + // Mock WindowMain with webContents + mockWindowMain = { + win: { + webContents: { + send: jest.fn(), + }, + }, + } as any; + + // Reset all mocks + jest.clearAllMocks(); + (globalShortcut.isRegistered as jest.Mock).mockReturnValue(false); + (globalShortcut.register as jest.Mock).mockReturnValue(true); + + TestBed.configureTestingModule({ + providers: [ + { provide: LogService, useValue: mockLogService }, + { provide: WindowMain, useValue: mockWindowMain }, + ], + }); + + // Create service manually since it doesn't use Angular DI + service = new MainDesktopAutotypeService(mockLogService, mockWindowMain); + }); + + afterEach(() => { + ipcHandlers.clear(); // Clear handler map + service.dispose(); + }); + + describe("constructor", () => { + it("should create service", () => { + expect(service).toBeTruthy(); + }); + + it("should initialize keyboard shortcut", () => { + expect(service.autotypeKeyboardShortcut).toBeDefined(); + }); + + it("should register IPC handlers", () => { + expect(ipcMain.on).toHaveBeenCalledWith(AUTOTYPE_IPC_CHANNELS.TOGGLE, expect.any(Function)); + expect(ipcMain.on).toHaveBeenCalledWith( + AUTOTYPE_IPC_CHANNELS.CONFIGURE, + expect.any(Function), + ); + expect(ipcMain.on).toHaveBeenCalledWith(AUTOTYPE_IPC_CHANNELS.EXECUTE, expect.any(Function)); + expect(ipcMain.on).toHaveBeenCalledWith( + "autofill.completeAutotypeError", + expect.any(Function), + ); + }); + }); + + describe("TOGGLE handler", () => { + it("should enable autotype when toggle is true", () => { + const toggleHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.TOGGLE); + + toggleHandler({}, true); + + expect(globalShortcut.register).toHaveBeenCalled(); + expect(mockLogService.debug).toHaveBeenCalledWith("Autotype enabled."); + }); + + it("should disable autotype when toggle is false", () => { + (globalShortcut.isRegistered as jest.Mock).mockReturnValue(true); + const toggleHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.TOGGLE); + + toggleHandler({}, false); + + expect(globalShortcut.unregister).toHaveBeenCalled(); + expect(mockLogService.debug).toHaveBeenCalledWith("Autotype disabled."); + }); + }); + + describe("CONFIGURE handler", () => { + it("should update keyboard shortcut with valid configuration", () => { + const config: AutotypeConfig = { + keyboardShortcut: ["Control", "Alt", "A"], + }; + + const mockNewShortcut = { + set: jest.fn().mockReturnValue(true), + getElectronFormat: jest.fn().mockReturnValue("Control+Alt+A"), + getArrayFormat: jest.fn().mockReturnValue(["Control", "Alt", "A"]), + }; + (AutotypeKeyboardShortcut as jest.Mock).mockReturnValue(mockNewShortcut); + + const configureHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.CONFIGURE); + configureHandler({}, config); + + expect(mockNewShortcut.set).toHaveBeenCalledWith(config.keyboardShortcut); + }); + + it("should log error with invalid keyboard shortcut", () => { + const config: AutotypeConfig = { + keyboardShortcut: ["Invalid", "Keys"], + }; + + const mockNewShortcut = { + set: jest.fn().mockReturnValue(false), + getElectronFormat: jest.fn(), + getArrayFormat: jest.fn(), + }; + (AutotypeKeyboardShortcut as jest.Mock).mockReturnValue(mockNewShortcut); + + const configureHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.CONFIGURE); + configureHandler({}, config); + + expect(mockLogService.error).toHaveBeenCalledWith( + "Configure autotype failed: the keyboard shortcut is invalid.", + ); + }); + + it("should register new shortcut if one already registered", () => { + (globalShortcut.isRegistered as jest.Mock) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + const config: AutotypeConfig = { + keyboardShortcut: ["Control", "Alt", "B"], + }; + + const mockNewShortcut = { + set: jest.fn().mockReturnValue(true), + getElectronFormat: jest.fn().mockReturnValue("Control+Alt+B"), + getArrayFormat: jest.fn().mockReturnValue(["Control", "Alt", "B"]), + }; + (AutotypeKeyboardShortcut as jest.Mock).mockReturnValue(mockNewShortcut); + + const configureHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.CONFIGURE); + configureHandler({}, config); + + expect(globalShortcut.unregister).toHaveBeenCalled(); + expect(globalShortcut.register).toHaveBeenCalled(); + }); + + it("should not change shortcut if it is the same", () => { + const config: AutotypeConfig = { + keyboardShortcut: ["Control", "Alt", "B"], + }; + + jest + .spyOn(service.autotypeKeyboardShortcut, "getElectronFormat") + .mockReturnValue("Control+Alt+B"); + + (globalShortcut.isRegistered as jest.Mock).mockReturnValue(true); + + const configureHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.CONFIGURE); + configureHandler({}, config); + + expect(mockLogService.debug).toHaveBeenCalledWith( + "setKeyboardShortcut() called but shortcut is not different from current.", + ); + }); + }); + + describe("EXECUTE handler", () => { + it("should execute autotype with valid vault data", async () => { + const vaultData: AutotypeVaultData = { + username: "testuser", + password: "testpass", + }; + + jest + .spyOn(service.autotypeKeyboardShortcut, "getArrayFormat") + .mockReturnValue(["Control", "Alt", "B"]); + + const executeHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.EXECUTE); + await executeHandler({}, vaultData); + + expect(autotype.typeInput).toHaveBeenCalledWith(expect.any(Array), ["Control", "Alt", "B"]); + }); + + it("should not execute autotype with empty username", () => { + const vaultData: AutotypeVaultData = { + username: "", + password: "testpass", + }; + + const executeHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.EXECUTE); + executeHandler({}, vaultData); + + expect(autotype.typeInput).not.toHaveBeenCalled(); + }); + + it("should not execute autotype with empty password", () => { + const vaultData: AutotypeVaultData = { + username: "testuser", + password: "", + }; + + const executeHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.EXECUTE); + executeHandler({}, vaultData); + + expect(autotype.typeInput).not.toHaveBeenCalled(); + }); + + it("should format input with tab separator", () => { + const mockNewShortcut = { + set: jest.fn().mockReturnValue(true), + getElectronFormat: jest.fn().mockReturnValue("Control+Alt+B"), + getArrayFormat: jest.fn().mockReturnValue(["Control", "Alt", "B"]), + }; + + (AutotypeKeyboardShortcut as jest.Mock).mockReturnValue(mockNewShortcut); + + const vaultData: AutotypeVaultData = { + username: "user", + password: "pass", + }; + + const executeHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.EXECUTE); + executeHandler({}, vaultData); + + // Verify the input array contains char codes for "user\tpass" + const expectedPattern = "user\tpass"; + const expectedArray = Array.from(expectedPattern).map((c) => c.charCodeAt(0)); + + expect(autotype.typeInput).toHaveBeenCalledWith(expectedArray, ["Control", "Alt", "B"]); + }); + }); + + describe("completeAutotypeError handler", () => { + it("should log autotype match errors", () => { + const matchError: AutotypeMatchError = { + windowTitle: "Test Window", + errorMessage: "No matching vault item", + }; + + const errorHandler = ipcHandlers.get("autofill.completeAutotypeError"); + errorHandler({}, matchError); + + expect(mockLogService.debug).toHaveBeenCalledWith( + "autofill.completeAutotypeError", + "No match for window: Test Window", + ); + expect(mockLogService.error).toHaveBeenCalledWith( + "autofill.completeAutotypeError", + "No matching vault item", + ); + }); + }); + + describe("disableAutotype", () => { + it("should unregister shortcut if registered", () => { + (globalShortcut.isRegistered as jest.Mock).mockReturnValue(true); + + service.disableAutotype(); + + expect(globalShortcut.unregister).toHaveBeenCalled(); + expect(mockLogService.debug).toHaveBeenCalledWith("Autotype disabled."); + }); + + it("should log debug message if shortcut not registered", () => { + (globalShortcut.isRegistered as jest.Mock).mockReturnValue(false); + + service.disableAutotype(); + + expect(globalShortcut.unregister).not.toHaveBeenCalled(); + expect(mockLogService.debug).toHaveBeenCalledWith( + "Autotype is not registered, implicitly disabled.", + ); + }); + }); + + describe("dispose", () => { + it("should remove all IPC listeners", () => { + service.dispose(); + + expect(ipcMain.removeAllListeners).toHaveBeenCalledWith(AUTOTYPE_IPC_CHANNELS.TOGGLE); + expect(ipcMain.removeAllListeners).toHaveBeenCalledWith(AUTOTYPE_IPC_CHANNELS.CONFIGURE); + expect(ipcMain.removeAllListeners).toHaveBeenCalledWith(AUTOTYPE_IPC_CHANNELS.EXECUTE); + }); + + it("should disable autotype", () => { + (globalShortcut.isRegistered as jest.Mock).mockReturnValue(true); + + service.dispose(); + + expect(globalShortcut.unregister).toHaveBeenCalled(); + }); + }); + + describe("enableAutotype (via TOGGLE handler)", () => { + it("should register global shortcut", () => { + const toggleHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.TOGGLE); + + toggleHandler({}, true); + + expect(globalShortcut.register).toHaveBeenCalledWith("Control+Alt+B", expect.any(Function)); + }); + + it("should not register if already registered", () => { + (globalShortcut.isRegistered as jest.Mock).mockReturnValue(true); + const toggleHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.TOGGLE); + + toggleHandler({}, true); + + expect(globalShortcut.register).not.toHaveBeenCalled(); + expect(mockLogService.debug).toHaveBeenCalledWith( + "Autotype is already enabled with this keyboard shortcut: Control+Alt+B", + ); + }); + + it("should log error if registration fails", () => { + (globalShortcut.register as jest.Mock).mockReturnValue(false); + const toggleHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.TOGGLE); + + toggleHandler({}, true); + + expect(mockLogService.error).toHaveBeenCalledWith("Failed to enable Autotype."); + }); + + it("should send window title to renderer on shortcut activation", () => { + (autotype.getForegroundWindowTitle as jest.Mock).mockReturnValue("Notepad"); + + const toggleHandler = ipcHandlers.get(AUTOTYPE_IPC_CHANNELS.TOGGLE); + toggleHandler({}, true); + + // Get the registered callback + const registeredCallback = (globalShortcut.register as jest.Mock).mock.calls[0][1]; + registeredCallback(); + + expect(autotype.getForegroundWindowTitle).toHaveBeenCalled(); + expect(mockWindowMain.win.webContents.send).toHaveBeenCalledWith( + AUTOTYPE_IPC_CHANNELS.LISTEN, + { windowTitle: "Notepad" }, + ); + }); + }); +}); diff --git a/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts b/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts index b26be92585e..8b241ade032 100644 --- a/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts +++ b/apps/desktop/src/autofill/models/main-autotype-keyboard-shortcut.ts @@ -1,4 +1,14 @@ -import { defaultWindowsAutotypeKeyboardShortcut } from "../services/desktop-autotype.service"; +/** + Electron's representation of modifier keys + +*/ +export const CONTROL_KEY_STR = "Control"; +export const ALT_KEY_STR = "Alt"; +export const SUPER_KEY_STR = "Super"; + +export const VALID_SHORTCUT_MODIFIER_KEYS: string[] = [CONTROL_KEY_STR, ALT_KEY_STR, SUPER_KEY_STR]; + +export const DEFAULT_KEYBOARD_SHORTCUT: string[] = [CONTROL_KEY_STR, ALT_KEY_STR, "B"]; /* This class provides the following: @@ -13,7 +23,7 @@ export class AutotypeKeyboardShortcut { private autotypeKeyboardShortcut: string[]; constructor() { - this.autotypeKeyboardShortcut = defaultWindowsAutotypeKeyboardShortcut; + this.autotypeKeyboardShortcut = DEFAULT_KEYBOARD_SHORTCUT; } /* @@ -51,14 +61,16 @@ export class AutotypeKeyboardShortcut { This private function validates the strArray input to make sure the array contains valid, currently accepted shortcut keys for Windows. - Valid windows shortcut keys: Control, Alt, Super, Shift, letters A - Z - Valid macOS shortcut keys: Control, Alt, Command, Shift, letters A - Z (not yet supported) + Valid shortcut keys: Control, Alt, Super, letters A - Z + Platform specifics: + - On Windows, Super maps to the Windows key. + - On MacOS, Super maps to the Command key. + - On MacOS, Alt maps to the Option key. See Electron keyboard shorcut docs for more info: https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts */ #keyboardShortcutIsValid(strArray: string[]) { - const VALID_SHORTCUT_CONTROL_KEYS: string[] = ["Control", "Alt", "Super", "Shift"]; const UNICODE_LOWER_BOUND = 65; // unicode 'A' const UNICODE_UPPER_BOUND = 90; // unicode 'Z' const MIN_LENGTH: number = 2; @@ -77,7 +89,7 @@ export class AutotypeKeyboardShortcut { // Ensure strArray is all modifier keys, and that the last key is a letter for (let i = 0; i < strArray.length; i++) { if (i < strArray.length - 1) { - if (!VALID_SHORTCUT_CONTROL_KEYS.includes(strArray[i])) { + if (!VALID_SHORTCUT_MODIFIER_KEYS.includes(strArray[i])) { return false; } } else { diff --git a/apps/desktop/src/autofill/services/desktop-autotype.service.spec.ts b/apps/desktop/src/autofill/services/desktop-autotype.service.spec.ts index 30cc800dd28..000242476ed 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype.service.spec.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype.service.spec.ts @@ -1,6 +1,363 @@ -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { TestBed } from "@angular/core/testing"; +import { BehaviorSubject } from "rxjs"; -import { getAutotypeVaultData } from "./desktop-autotype.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { DeviceType } from "@bitwarden/common/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { LogService } from "@bitwarden/logging"; + +import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service"; +import { DesktopAutotypeService, getAutotypeVaultData } from "./desktop-autotype.service"; + +describe("DesktopAutotypeService", () => { + let service: DesktopAutotypeService; + + // Mock dependencies + let mockAccountService: jest.Mocked; + let mockAuthService: jest.Mocked; + let mockCipherService: jest.Mocked; + let mockConfigService: jest.Mocked; + let mockGlobalStateProvider: jest.Mocked; + let mockPlatformUtilsService: jest.Mocked; + let mockBillingAccountProfileStateService: jest.Mocked; + let mockDesktopAutotypePolicy: jest.Mocked; + let mockLogService: jest.Mocked; + + // Mock GlobalState objects + let mockAutotypeEnabledState: any; + let mockAutotypeKeyboardShortcutState: any; + + // BehaviorSubjects for reactive state + let autotypeEnabledSubject: BehaviorSubject; + let autotypeKeyboardShortcutSubject: BehaviorSubject; + let activeAccountSubject: BehaviorSubject; + let activeAccountStatusSubject: BehaviorSubject; + let hasPremiumSubject: BehaviorSubject; + let featureFlagSubject: BehaviorSubject; + let autotypeDefaultPolicySubject: BehaviorSubject; + let cipherViewsSubject: BehaviorSubject; + + beforeEach(() => { + // Initialize BehaviorSubjects + autotypeEnabledSubject = new BehaviorSubject(null); + autotypeKeyboardShortcutSubject = new BehaviorSubject(["Control", "Shift", "B"]); + activeAccountSubject = new BehaviorSubject({ id: "user-123" }); + activeAccountStatusSubject = new BehaviorSubject( + AuthenticationStatus.Unlocked, + ); + hasPremiumSubject = new BehaviorSubject(true); + featureFlagSubject = new BehaviorSubject(true); + autotypeDefaultPolicySubject = new BehaviorSubject(false); + cipherViewsSubject = new BehaviorSubject([]); + + // Mock GlobalState objects + mockAutotypeEnabledState = { + state$: autotypeEnabledSubject.asObservable(), + update: jest.fn().mockImplementation(async (configureState, options) => { + const newState = configureState(autotypeEnabledSubject.value, null); + + // Handle shouldUpdate option + if (options?.shouldUpdate && !options.shouldUpdate(autotypeEnabledSubject.value)) { + return autotypeEnabledSubject.value; + } + + autotypeEnabledSubject.next(newState); + return newState; + }), + }; + + mockAutotypeKeyboardShortcutState = { + state$: autotypeKeyboardShortcutSubject.asObservable(), + update: jest.fn().mockImplementation(async (configureState) => { + const newState = configureState(autotypeKeyboardShortcutSubject.value, null); + autotypeKeyboardShortcutSubject.next(newState); + return newState; + }), + }; + + // Mock GlobalStateProvider + mockGlobalStateProvider = { + get: jest.fn().mockImplementation((keyDef) => { + if (keyDef.key === "autotypeEnabled") { + return mockAutotypeEnabledState; + } + if (keyDef.key === "autotypeKeyboardShortcut") { + return mockAutotypeKeyboardShortcutState; + } + }), + } as any; + + // Mock AccountService + mockAccountService = { + activeAccount$: activeAccountSubject.asObservable(), + } as any; + + // Mock AuthService + mockAuthService = { + activeAccountStatus$: activeAccountStatusSubject.asObservable(), + } as any; + + // Mock CipherService + mockCipherService = { + cipherViews$: jest.fn().mockReturnValue(cipherViewsSubject.asObservable()), + } as any; + + // Mock ConfigService + mockConfigService = { + getFeatureFlag$: jest.fn().mockReturnValue(featureFlagSubject.asObservable()), + } as any; + + // Mock PlatformUtilsService + mockPlatformUtilsService = { + getDevice: jest.fn().mockReturnValue(DeviceType.WindowsDesktop), + } as any; + + // Mock BillingAccountProfileStateService + mockBillingAccountProfileStateService = { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(hasPremiumSubject.asObservable()), + } as any; + + // Mock DesktopAutotypeDefaultSettingPolicy + mockDesktopAutotypePolicy = { + autotypeDefaultSetting$: autotypeDefaultPolicySubject.asObservable(), + } as any; + + // Mock LogService + mockLogService = { + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + } as any; + + // Mock ipc (global) + global.ipc = { + autofill: { + listenAutotypeRequest: jest.fn(), + configureAutotype: jest.fn(), + toggleAutotype: jest.fn(), + }, + } as any; + + TestBed.configureTestingModule({ + providers: [ + DesktopAutotypeService, + { provide: AccountService, useValue: mockAccountService }, + { provide: AuthService, useValue: mockAuthService }, + { provide: CipherService, useValue: mockCipherService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: GlobalStateProvider, useValue: mockGlobalStateProvider }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { + provide: BillingAccountProfileStateService, + useValue: mockBillingAccountProfileStateService, + }, + { provide: DesktopAutotypeDefaultSettingPolicy, useValue: mockDesktopAutotypePolicy }, + { provide: LogService, useValue: mockLogService }, + ], + }); + + service = TestBed.inject(DesktopAutotypeService); + }); + + afterEach(() => { + jest.clearAllMocks(); + service.ngOnDestroy(); + }); + + describe("constructor", () => { + it("should create service", () => { + expect(service).toBeTruthy(); + }); + + it("should initialize observables", () => { + expect(service.autotypeEnabledUserSetting$).toBeDefined(); + expect(service.autotypeKeyboardShortcut$).toBeDefined(); + }); + }); + + describe("init", () => { + it("should register autotype request listener on Windows", async () => { + await service.init(); + + expect(global.ipc.autofill.listenAutotypeRequest).toHaveBeenCalled(); + }); + + it("should not initialize on non-Windows platforms", async () => { + mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.MacOsDesktop); + + await service.init(); + + expect(global.ipc.autofill.listenAutotypeRequest).not.toHaveBeenCalled(); + }); + + it("should configure autotype when keyboard shortcut changes", async () => { + await service.init(); + + // Allow observables to emit + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(global.ipc.autofill.configureAutotype).toHaveBeenCalled(); + }); + + it("should toggle autotype when feature enabled state changes", async () => { + autotypeEnabledSubject.next(true); + + await service.init(); + + // Allow observables to emit + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(global.ipc.autofill.toggleAutotype).toHaveBeenCalled(); + }); + + it("should enable autotype when policy is true and user setting is null", async () => { + autotypeEnabledSubject.next(null); + autotypeDefaultPolicySubject.next(true); + + await service.init(); + + // Allow observables to emit + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockAutotypeEnabledState.update).toHaveBeenCalled(); + expect(autotypeEnabledSubject.value).toBe(true); + }); + }); + + describe("setAutotypeEnabledState", () => { + it("should update autotype enabled state", async () => { + await service.setAutotypeEnabledState(true); + + expect(mockAutotypeEnabledState.update).toHaveBeenCalled(); + expect(autotypeEnabledSubject.value).toBe(true); + }); + + it("should not update if value has not changed", async () => { + autotypeEnabledSubject.next(true); + + await service.setAutotypeEnabledState(true); + + // Update was called but shouldUpdate prevented the change + expect(mockAutotypeEnabledState.update).toHaveBeenCalled(); + expect(autotypeEnabledSubject.value).toBe(true); + }); + }); + + describe("setAutotypeKeyboardShortcutState", () => { + it("should update keyboard shortcut state", async () => { + const newShortcut = ["Control", "Alt", "A"]; + + await service.setAutotypeKeyboardShortcutState(newShortcut); + + expect(mockAutotypeKeyboardShortcutState.update).toHaveBeenCalled(); + expect(autotypeKeyboardShortcutSubject.value).toEqual(newShortcut); + }); + }); + + describe("matchCiphersToWindowTitle", () => { + it("should match ciphers with matching apptitle URIs", async () => { + const mockCiphers = [ + { + login: { + username: "user1", + password: "pass1", + uris: [{ uri: "apptitle://notepad" }], + }, + deletedDate: null, + }, + { + login: { + username: "user2", + password: "pass2", + uris: [{ uri: "apptitle://chrome" }], + }, + deletedDate: null, + }, + ]; + + cipherViewsSubject.next(mockCiphers); + + const result = await service.matchCiphersToWindowTitle("Notepad - Document.txt"); + + expect(result).toHaveLength(1); + expect(result[0].login.username).toBe("user1"); + }); + + it("should filter out deleted ciphers", async () => { + const mockCiphers = [ + { + login: { + username: "user1", + password: "pass1", + uris: [{ uri: "apptitle://notepad" }], + }, + deletedDate: new Date(), + }, + ]; + + cipherViewsSubject.next(mockCiphers); + + const result = await service.matchCiphersToWindowTitle("Notepad"); + + expect(result).toHaveLength(0); + }); + + it("should filter out ciphers without username or password", async () => { + const mockCiphers = [ + { + login: { + username: null, + password: "pass1", + uris: [{ uri: "apptitle://notepad" }], + }, + deletedDate: null, + }, + ]; + + cipherViewsSubject.next(mockCiphers); + + const result = await service.matchCiphersToWindowTitle("Notepad"); + + expect(result).toHaveLength(0); + }); + + it("should perform case-insensitive matching", async () => { + const mockCiphers = [ + { + login: { + username: "user1", + password: "pass1", + uris: [{ uri: "apptitle://NOTEPAD" }], + }, + deletedDate: null, + }, + ]; + + cipherViewsSubject.next(mockCiphers); + + const result = await service.matchCiphersToWindowTitle("notepad - document.txt"); + + expect(result).toHaveLength(1); + }); + }); + + describe("ngOnDestroy", () => { + it("should complete destroy subject", () => { + const destroySpy = jest.spyOn(service["destroy$"], "complete"); + + service.ngOnDestroy(); + + expect(destroySpy).toHaveBeenCalled(); + }); + }); +}); describe("getAutotypeVaultData", () => { it("should return vault data when cipher has username and password", () => { diff --git a/apps/desktop/src/autofill/services/desktop-autotype.service.ts b/apps/desktop/src/autofill/services/desktop-autotype.service.ts index 46fec662d7a..d108577567d 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype.service.ts @@ -33,11 +33,10 @@ import { UserId } from "@bitwarden/user-core"; import { AutotypeConfig } from "../models/autotype-config"; import { AutotypeVaultData } from "../models/autotype-vault-data"; +import { DEFAULT_KEYBOARD_SHORTCUT } from "../models/main-autotype-keyboard-shortcut"; import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service"; -export const defaultWindowsAutotypeKeyboardShortcut: string[] = ["Control", "Shift", "B"]; - export const AUTOTYPE_ENABLED = new KeyDefinition( AUTOTYPE_SETTINGS_DISK, "autotypeEnabled", @@ -72,10 +71,9 @@ export class DesktopAutotypeService implements OnDestroy { private readonly isPremiumAccount$: Observable; // The enabled/disabled state from the user settings menu - autotypeEnabledUserSetting$: Observable; + autotypeEnabledUserSetting$: Observable = of(false); - // The keyboard shortcut from the user settings menu - autotypeKeyboardShortcut$: Observable = of(defaultWindowsAutotypeKeyboardShortcut); + autotypeKeyboardShortcut$: Observable = of(DEFAULT_KEYBOARD_SHORTCUT); private destroy$ = new Subject(); @@ -106,7 +104,7 @@ export class DesktopAutotypeService implements OnDestroy { ); this.autotypeKeyboardShortcut$ = this.autotypeKeyboardShortcut.state$.pipe( - map((shortcut) => shortcut ?? defaultWindowsAutotypeKeyboardShortcut), + map((shortcut) => shortcut ?? DEFAULT_KEYBOARD_SHORTCUT), takeUntil(this.destroy$), ); } diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index 720e0dde7c2..c879ae0cc70 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Nuwe URI" }, @@ -2411,6 +2415,10 @@ "message": "Is u seker u wil hierdie Send skrap?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopieer Send-skakel na knipbord", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index d6c42f5883a..77a73a6c0b4 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "رابط جديد" }, @@ -2411,6 +2415,10 @@ "message": "هل أنت متأكد من أنك تريد حذف هذا الإرسال؟", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "نسخ رابط إرسال إلى الحافظة", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index ba6cad30e4f..31735e88ef1 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Yeni URI" }, @@ -2411,6 +2415,10 @@ "message": "Bu \"Send\"i silmək istədiyinizə əminsiniz?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Send keçidini lövhəyə kopyala", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2499,7 +2507,7 @@ "message": "Ana parolunuz təşkilatınızdakı siyasətlərdən birinə və ya bir neçəsinə uyğun gəlmir. Seyfə erişmək üçün ana parolunuzu indi güncəlləməlisiniz. Davam etsəniz, hazırkı seansdan çıxış etmiş və təkrar giriş etməli olacaqsınız. Digər cihazlardakı aktiv seanslar bir saata qədər aktiv qalmağa davam edə bilər." }, "changePasswordWarning": { - "message": "Parolunuzu dəyişdirdikdən sonra yeni parolunuzla giriş etməli olacaqsınız. Digər cihazlardakı aktiv seanslar bir saat ərzində çıxış sonlandırılacaq." + "message": "Parolunuzu dəyişdirdikdən sonra yeni parolunuzla giriş etməli olacaqsınız. Digər cihazlardakı aktiv sessiyalar bir saat ərzində sonlandırılacaq." }, "accountRecoveryUpdateMasterPasswordSubtitle": { "message": "Hesabın geri qaytarılması prosesini tamamlamaq üçün ana parolunuzu dəyişdirin." @@ -4031,10 +4039,16 @@ "message": "Send ilə həssas məlumatlar əmniyyətdədir", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "İstənilən platformada faylları və veriləri hər kəslə güvənli şəkildə paylaşın. Məlumatlarınız, ifşa olunmamaq üçün ucdan-uca şifrələnmiş qalacaq.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Cəld parol yaradın" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Arxivdən çıxart" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Arxivdəki elementlər" }, @@ -4313,6 +4330,21 @@ "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?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Poçt kodu" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Sessiya vaxt bitməsi" }, + "resizeSideNavigation": { + "message": "Yan naviqasiyanı yeni. ölçüləndir" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Bu ayar, təşkilatınız tərəfindən idarə olunur." }, diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index 50f7bdc668e..6bb3bf31013 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Новы URI" }, @@ -2411,6 +2415,10 @@ "message": "Вы сапраўды хочаце выдаліць гэты Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Скапіяваць спасылку на Send у буфер абмену", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index 539cfb344aa..aceff28455f 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Ново", + "description": "for adding new items" + }, "newUri": { "message": "Нов адрес" }, @@ -2411,6 +2415,10 @@ "message": "Сигурни ли сте, че искате да изтриете това изпращане?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Копиране на връзката към Изпращането", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Копиране на връзката към изпращането", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Изпращайте чувствителна информация сигурно", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Няма резултати от търсенето" + }, "sendsBodyNoItems": { "message": "Споделяйте сигурно файлове и данни с всекиго, през всяка система. Информацията Ви ще бъде защитена с шифроване от край до край, а видимостта ѝ ще бъде ограничена.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Изчистете филтрите или опитайте да търсите нещо друго" + }, "generatorNudgeTitle": { "message": "Създавайте пароли бързо" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Изваждане от архива" }, + "archived": { + "message": "Архивирано" + }, "itemsInArchive": { "message": "Елементи в архива" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" }, + "unArchiveAndSave": { + "message": "Разархивиране и запазване" + }, + "restartPremium": { + "message": "Подновяване на платения абонамент" + }, + "premiumSubscriptionEnded": { + "message": "Вашият абонамент за платения план е приключил" + }, + "premiumSubscriptionEndedDesc": { + "message": "Ако искате отново да получите достъп до архива си, трябва да подновите платения си абонамент. Ако редактирате данните за архивиран елемент преди подновяването, той ще бъде върнат в трезора." + }, + "itemRestored": { + "message": "Записът бе възстановен" + }, "zipPostalCodeLabel": { "message": "Пощенски код" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Изтичане на времето за сесията" }, + "resizeSideNavigation": { + "message": "Преоразмеряване на страничната навигация" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Тази настройка се управлява от организацията Ви." }, diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index bde81f83b42..01a9f1d57d6 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "নতুন URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index 3ed7dee9e92..a90b00d16e5 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Novi URI" }, @@ -2411,6 +2415,10 @@ "message": "Sigurno želiš izbrisati ovaj Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopiraj link Send-a", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index 57041a87a31..41283482c62 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Nova URI" }, @@ -2411,6 +2415,10 @@ "message": "Esteu segur que voleu suprimir aquest Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copia l'enllaç Send al porta-retalls", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index fe0c814f2a2..436839c4d8b 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Nová", + "description": "for adding new items" + }, "newUri": { "message": "Nová URI" }, @@ -2411,6 +2415,10 @@ "message": "Opravdu chcete smazat tento Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Kopírovat odkaz pro Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Zkopírovat odkaz Send do schránky", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Posílejte citlivé informace bezpečně", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Nebyly vráceny žádné výsledky hledání" + }, "sendsBodyNoItems": { "message": "Sdílejte bezpečně soubory a data s kýmkoli na libovolné platformě. Vaše informace zůstanou šifrovány a zároveň omezují expozici.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Vymažte filtry nebo zkuste jiný hledaný výraz" + }, "generatorNudgeTitle": { "message": "Rychlé vytvoření hesla" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Odebrat z archivu" }, + "archived": { + "message": "Archivováno" + }, "itemsInArchive": { "message": "Položky v archivu" }, @@ -4313,6 +4330,21 @@ "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?" }, + "unArchiveAndSave": { + "message": "Odebrat z archivu a uložit" + }, + "restartPremium": { + "message": "Restartovat Premium" + }, + "premiumSubscriptionEnded": { + "message": "Vaše předplatné Premium skončilo" + }, + "premiumSubscriptionEndedDesc": { + "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." + }, + "itemRestored": { + "message": "Položka byla obnovena" + }, "zipPostalCodeLabel": { "message": "ZIP / PSČ" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Časový limit relace" }, + "resizeSideNavigation": { + "message": "Změnit velikost boční navigace" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Tato nastavení je spravováno Vaší organizací." }, diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index af6bb971ab3..e00bfb52e41 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "New URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index 5b864c57d62..5129d83839c 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Ny URI" }, @@ -2411,6 +2415,10 @@ "message": "Sikker på, at du vil slette denne Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopiér Send-link til udklipsholder", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index b49654c4dbe..b5deafa055e 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Neu", + "description": "for adding new items" + }, "newUri": { "message": "Neue URL" }, @@ -2411,6 +2415,10 @@ "message": "Bist du sicher, dass du dieses Send löschen möchtest?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Send-Link kopieren", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Send-Link in Zwischenablage kopieren", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Sensible Informationen sicher versenden", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Keine Suchergebnisse gefunden" + }, "sendsBodyNoItems": { "message": "Teile Dateien und Daten sicher mit jedem auf jeder Plattform. Deine Informationen bleiben Ende-zu-Ende-verschlüsselt, während die Verbreitung begrenzt wird.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Filter löschen oder es mit einem anderen Suchbegriff versuchen" + }, "generatorNudgeTitle": { "message": "Passwörter schnell erstellen" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Wiederherstellen" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Einträge im Archiv" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archivierte Einträge werden von allgemeinen Suchergebnissen und Auto-Ausfüllen-Vorschlägen ausgeschlossen. Bist du sicher, dass du diesen Eintrag archivieren möchtest?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "PLZ / Postleitzahl" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Sitzungs-Timeout" }, + "resizeSideNavigation": { + "message": "Größe der Seitennavigation ändern" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Diese Einstellung wird von deiner Organisation verwaltet." }, diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index 68b773c74f4..d8329d7d04b 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Νέο URI" }, @@ -2411,6 +2415,10 @@ "message": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το Send;", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Αντιγραφή συνδέσμου Send στο πρόχειρο", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Γρήγορη δημιουργία κωδικών πρόσβασης" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 9be96a62589..b00233457ec 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "New URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Type shortcut" }, - "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Invalid shortcut" @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4461,7 +4493,7 @@ "placeholders": { "organization": { "content": "$1", - "example": "My Org Name" + "example": "My Org Name" } } }, @@ -4470,7 +4502,7 @@ "placeholders": { "organization": { "content": "$1", - "example": "My Org Name" + "example": "My Org Name" } } }, diff --git a/apps/desktop/src/locales/en_GB/messages.json b/apps/desktop/src/locales/en_GB/messages.json index 3ad78742666..d2f191f08c3 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "New URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organisation." }, diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index 987d8d0925c..399087bad95 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "New URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "PIN" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organisation." }, diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index 1e70ad9a180..ebbdce12c3d 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Nova URI" }, @@ -2411,6 +2415,10 @@ "message": "Ĉu vi certas, ke vi volas forigi tiun Send'on?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index 9ce531994a0..0b456800964 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Nueva URI" }, @@ -2411,6 +2415,10 @@ "message": "¿Está seguro de que quiere eliminar este envío?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copiar el enlace del Send al portapapeles", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Envía información sensible de forma segura", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Comparte archivos y datos de forma segura con cualquiera, en cualquier plataforma. Tu información permanecerá encriptada de extremo a extremo, limitando su exposición.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Crear contraseñas rápidamente" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Desarchivar" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Código postal" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index 0c96d531e40..f009785e27e 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Uus URI" }, @@ -2411,6 +2415,10 @@ "message": "Soovid tõesti selle Sendi kustutada?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopeeri Sendi link lõikelauale", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index e86f29770d6..47b3c7ac33f 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "URI berria" }, @@ -2411,6 +2415,10 @@ "message": "Ziur al zaude Send hau ezabatu nahi duzula?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopiatu Send esteka arbelean", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index 45cda22a45f..977e383fd06 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "نشانی اینترنتی جدید" }, @@ -2411,6 +2415,10 @@ "message": "آیا مطمئن هستید که می‌خواهید این ارسال را حذف کنید؟", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "کپی پیوند ارسال به حافظه موقت", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "اطلاعات حساس را به‌صورت ایمن ارسال کنید", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "پرونده‌ها و داده‌های خود را به‌صورت امن با هر کسی، در هر پلتفرمی به اشتراک بگذارید. اطلاعات شما در حین اشتراک‌گذاری به‌طور کامل رمزگذاری انتها به انتها باقی خواهد ماند و میزان افشا محدود می‌شود.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "ساخت سریع کلمات عبور" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "خارج کردن از بایگانی" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "آیتم‌های موجود در بایگانی" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "آیتم‌های بایگانی‌شده از نتایج جستجوی عمومی و پیشنهاد ها پر کردن خودکار حذف می‌شوند. آیا مطمئن هستید که می‌خواهید این آیتم را بایگانی کنید؟" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "کد پستی" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "وقفه زمانی نشست" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index 7cee4e0c5e5..30588bf9ddc 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Uusi URI" }, @@ -2411,6 +2415,10 @@ "message": "Haluatko varmasti poistaa Sendin?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopioi Send-linkki leikepöydälle", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index 39651e16f4a..0a8b0e89fd9 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Bagong URI" }, @@ -2411,6 +2415,10 @@ "message": "Sigurado ka bang gusto mo na i-delete ang Ipadala na ito?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopyahin Ipadala ang link sa clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index 503c93f9f19..577dfad3511 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Nouvel URI" }, @@ -2411,6 +2415,10 @@ "message": "Êtes-vous sûr de vouloir supprimer ce Send ?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copier le lien du Send dans le presse-papier", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Envoyez des informations sensibles, en toute sécurité", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Partagez des fichiers et des données en toute sécurité avec n'importe qui, sur n'importe quelle plateforme. Vos informations resteront chiffrées de bout en bout tout en limitant l'exposition.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Créer rapidement des mots de passe" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Désarchiver" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Éléments dans l'archive" }, @@ -4313,6 +4330,21 @@ "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 ?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Code postal" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Délai d'expiration de la session" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index 2d0019a1f31..90eee288681 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "New URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index cde90fcc7ef..f723b37d85f 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "כתובת חדשה" }, @@ -2411,6 +2415,10 @@ "message": "האם אתה בטוח שברצונך למחוק סֵנְד זה?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "העתק קישור סֵנְד ללוח", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "שלח מידע רגיש באופן בטוח", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "שתף קבצים ונתונים באופן מאובטח עם כל אחד, בכל פלטפורמה. המידע שלך יישאר מוצפן מקצה־לקצה תוך הגבלת חשיפה.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "צור סיסמאות במהירות" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "הסר מהארכיון" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "פריטים בארכיון" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "פריטים בארכיון מוחרגים מתוצאות חיפוש כללי והצעות למילוי אוטומטי. האם אתה בטוח שברצונך להעביר פריט זה לארכיון?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "מיקוד" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "פסק זמן להפעלה" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index d3f65bef701..2c7e0394c3e 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "New URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index 10cf3fac635..ce8eaddc1a3 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Novi URI" }, @@ -2411,6 +2415,10 @@ "message": "Sigurno želiš izbrisati ovaj Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopiraj vezu na Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Sigurno pošalji osjetljive podatke", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Sigurno dijeli datoteke i podatke s bilo kime, na bilo kojoj platformi. Tvoji podaci ostaju kriptirani uz ograničenje izloženosti.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Brzo stvori lozinke" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Poništi arhiviranje" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Stavke u arhivi" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Arhivirane stavke biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune. Sigurno želiš arhivirati?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "Poštanski broj" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Istek sesije" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index 8cb8897ab09..374136ee556 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Új", + "description": "for adding new items" + }, "newUri": { "message": "Új URI" }, @@ -2411,6 +2415,10 @@ "message": "Biztosan törlésre kerüljön ez a küldés?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Send hivatkozás másolása", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Hivatkozás küldése másolás a vágólapra", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Érzékeny információt küldése biztonságosan", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Nincsenek visszakapott keresési eredmények." + }, "sendsBodyNoItems": { "message": "Fájlok vagy adatok megosztása biztonságosan bárkivel, bármilyen platformon. Az információk titkosítva maradnak a végpontokon, korlátozva a kitettséget.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Töröljük a szűrőket vagy próbálkozzunk másik keresési kifejezéssel." + }, "generatorNudgeTitle": { "message": "Jelszavak gyors létrehozása" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Visszavétel archívumból" }, + "archived": { + "message": "Archiválva" + }, "itemsInArchive": { "message": "Archívum elemek száma" }, @@ -4313,6 +4330,21 @@ "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?" }, + "unArchiveAndSave": { + "message": "Archiválás visszavonása és mentés" + }, + "restartPremium": { + "message": "Prémium előfizetés újraindítása" + }, + "premiumSubscriptionEnded": { + "message": "A Prémium előfizetés véget ért." + }, + "premiumSubscriptionEndedDesc": { + "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." + }, + "itemRestored": { + "message": "Az elem visszaállításra került." + }, "zipPostalCodeLabel": { "message": "Irányítószám" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Munkamenet időkifutás" }, + "resizeSideNavigation": { + "message": "Oldalnavigáció átméretezés" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Ezt a beállítást a szervezet lezeli." }, diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index 32a030e584c..487a84c11b6 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "URl Baru" }, @@ -2411,6 +2415,10 @@ "message": "Anda yakin ingin menghapus Send ini?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Salin Send tautan ke papan klip", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index bc3076bbc3c..175b6b19772 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Nuovo URI" }, @@ -2411,6 +2415,10 @@ "message": "Sei sicuro di voler eliminare questo Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copia il link al Send negli appunti", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Invia informazioni sensibili in modo sicuro", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Condividi facilmente file e dati con chiunque, su qualsiasi piattaforma. Le tue informazioni saranno crittografate end-to-end per la massima sicurezza.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Crea rapidamente password sicure" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Rimuovi dall'Archivio" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Elementi archiviati" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Gli elementi archiviati sono esclusi dai risultati di ricerca e dall'auto-riempimento. Vuoi davvero archiviare questo elemento?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "CAP / codice postale" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Timeout della sessione" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Questa impostazione è gestita dalla tua organizzazione." }, diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index e517bdcbe72..438828aa1ed 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "新しい URI" }, @@ -2411,6 +2415,10 @@ "message": "この Send を削除してもよろしいですか?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Send リンクをクリップボードにコピー", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index caf75a81f38..d1fe2e2b05f 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "ახალი URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index 2d0019a1f31..90eee288681 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "New URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index edfb1b6dd6e..b4509a61d57 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "ಹೊಸ ಯುಆರ್ಐ" }, @@ -2411,6 +2415,10 @@ "message": "ಈ ಕಳುಹಿಸುವಿಕೆಯನ್ನು ಅಳಿಸಲು ನೀವು ಖಚಿತವಾಗಿ ಬಯಸುವಿರಾ?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "ನಕಲಿಸಿ ಕ್ಲಿಪ್‌ಬೋರ್ಡ್‌ಗೆ ಲಿಂಕ್ ಕಳುಹಿಸಿ", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index 4d4e3221651..3717ffbdc68 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "새 URI" }, @@ -2411,6 +2415,10 @@ "message": "정말 이 Send를 삭제하시겠습니까?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Send 링크를 클립보드에 복사", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index ead4c1d89f9..71bd35ef34f 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Naujas URI" }, @@ -2411,6 +2415,10 @@ "message": "Ar tikrai norite ištrinti šį Sendą?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopijuoti Sendą į iškarpinę", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index 427a6ac2e16..3f4aede37f1 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Jauns", + "description": "for adding new items" + }, "newUri": { "message": "Jauns URI" }, @@ -2411,6 +2415,10 @@ "message": "Vai tiešām izdzēst šo Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Ievietot Send saiti starpliktuvē", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Ievietot Send saiti starpliktuvē", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Drošā veidā nosūti jūtīgu informāciju", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Nekas netika atrasts" + }, "sendsBodyNoItems": { "message": "Kopīgo datnes un datus drošā veidā ar ikvienu jebkurā platformā! Tava informācija paliks pilnībā šifrēta, vienlaikus ierobežojot riskantumu.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Jānotīra atsijātāji vai jāmēģina cits meklēšanas vaicājums" + }, "generatorNudgeTitle": { "message": "Ātra paroļu izveidošana" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Atcelt arhivēšanu" }, + "archived": { + "message": "Arhivēts" + }, "itemsInArchive": { "message": "Vienumi arhīvā" }, @@ -4313,6 +4330,21 @@ "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?" }, + "unArchiveAndSave": { + "message": "Atcelt arhivēšanu un saglabāt" + }, + "restartPremium": { + "message": "Atsākt Premium" + }, + "premiumSubscriptionEnded": { + "message": "Tavs Premium abonements beidzās" + }, + "premiumSubscriptionEndedDesc": { + "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ā." + }, + "itemRestored": { + "message": "Vienums tika atjaunots" + }, "zipPostalCodeLabel": { "message": "ZIP / Pasta indekss" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Sesijas noildze" }, + "resizeSideNavigation": { + "message": "Mainīt sānu pārvietošanās joslas izmēru" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Šo iestatījumu pārvalda apvienība." }, diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index a5a2212b2b1..312af4a6c1d 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Novi link" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index d71af9f752a..debdac6c8c7 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "പുതിയ URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index 2d0019a1f31..90eee288681 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "New URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index 12c10bbcd3a..db7180a84b9 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "New URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index 069250a562a..4338e219145 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Ny URI" }, @@ -2411,6 +2415,10 @@ "message": "Er du sikker på at du vil slette denne Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopier Send-lenke til utklippstavlen", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index a37dc2f0b5f..6d103b6bda6 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "नयाँ URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index 824bad9508d..185007135c5 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Nieuw", + "description": "for adding new items" + }, "newUri": { "message": "Nieuwe URI" }, @@ -2411,6 +2415,10 @@ "message": "Weet je zeker dat je deze Send wilt verwijderen?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Send-koppeling kopiëren", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Send-link naar klembord kopiëren", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Gevoelige informatie veilig versturen", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Geen resultaten teruggekregen" + }, "sendsBodyNoItems": { "message": "Deel bestanden en gegevens veilig met iedereen, op elk platform. Je informatie blijft end-to-end versleuteld terwijl en blootstelling beperkt.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Wis filters of probeer een andere zoekterm" + }, "generatorNudgeTitle": { "message": "Snel wachtwoorden maken" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Dearchiveren" }, + "archived": { + "message": "Gearchiveerd" + }, "itemsInArchive": { "message": "Items in archief" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Gearchiveerde items worden uitgesloten van algemene zoekresultaten en automatische invulsuggesties. Weet je zeker dat je dit item wilt archiveren?" }, + "unArchiveAndSave": { + "message": "Dearchiveren en opslaan" + }, + "restartPremium": { + "message": "Premium herstarten" + }, + "premiumSubscriptionEnded": { + "message": "Je Premium-abonnement is afgelopen" + }, + "premiumSubscriptionEndedDesc": { + "message": "Herstart je Premium-abonnement om toegang tot je archief te krijgen. Als je de details wijzigt voor een gearchiveerd item voor het opnieuw opstarten, zal het terug naar je kluis worden verplaatst." + }, + "itemRestored": { + "message": "Item is hersteld" + }, "zipPostalCodeLabel": { "message": "Postcode" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Sessietime-out" }, + "resizeSideNavigation": { + "message": "Formaat zijnavigatie wijzigen" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Deze instelling wordt beheerd door je organisatie." }, diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index 3fc7075911c..2a1c43ae342 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Ny URI" }, @@ -2411,6 +2415,10 @@ "message": "Er du sikker på at du vil fjerne denne Send-en?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopier Send-lenke til utklippstavle", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index 2307e43adab..9f95f1c0d27 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "New URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index 1724a713a30..2f75826a7b5 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Nowy element", + "description": "for adding new items" + }, "newUri": { "message": "Nowy URI" }, @@ -2411,6 +2415,10 @@ "message": "Czy na pewno chcesz usunąć wysyłkę?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Kopiuj link wysyłki", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopiuj link wysyłki do schowka", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3923,7 +3931,7 @@ "message": "You can set up two-step login as an alternative way to protect your account or change your email to one you can access." }, "remindMeLater": { - "message": "Remind me later" + "message": "Przypomnij później" }, "newDeviceVerificationNoticePageOneFormContent": { "message": "Do you have reliable access to your email, $EMAIL$?", @@ -3944,34 +3952,34 @@ "message": "Turn on two-step login" }, "changeAcctEmail": { - "message": "Change account email" + "message": "Zmień adres e-mail konta" }, "passkeyLogin": { - "message": "Log in with passkey?" + "message": "Zalogować się kluczem dostępu?" }, "savePasskeyQuestion": { - "message": "Save passkey?" + "message": "Zapisać klucz dostępu?" }, "saveNewPasskey": { - "message": "Save as new login" + "message": "Zapisz jako nowy dane logowania" }, "savePasskeyNewLogin": { - "message": "Save passkey as new login" + "message": "Zapisz klucz dostępu jako nowe dane logowania" }, "noMatchingLoginsForSite": { - "message": "No matching logins for this site" + "message": "Brak pasujących danych logowania dla tej strony" }, "overwritePasskey": { - "message": "Overwrite passkey?" + "message": "Zastąpić klucz dostępu?" }, "unableToSavePasskey": { - "message": "Unable to save passkey" + "message": "Nie można zapisać klucza dostępu" }, "alreadyContainsPasskey": { - "message": "This item already contains a passkey. Are you sure you want to overwrite the current passkey?" + "message": "Element zawiera już klucz dostępu. Czy na pewno chcesz zastąpić obecny klucz dostępu?" }, "passkeyAlreadyExists": { - "message": "A passkey already exists for this application." + "message": "Klucz dostępu dla tej aplikacji już istnieje." }, "applicationDoesNotSupportDuplicates": { "message": "This application does not support duplicates." @@ -4031,10 +4039,16 @@ "message": "Wysyłaj poufne informacje w bezpieczny sposób", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Brak pasujących elementów" + }, "sendsBodyNoItems": { "message": "Udostępniaj pliki i teksty każdemu na dowolnej platformie. Informacje będę szyfrowane end-to-end, zapewniając poufność.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Wyczyść filtry lub użyj innej frazy" + }, "generatorNudgeTitle": { "message": "Szybkie tworzenie haseł" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Usuń z archiwum" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Elementy w archiwum" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Zarchiwizowane elementy są wykluczone z wyników wyszukiwania i sugestii autouzupełniania. Czy na pewno chcesz archiwizować element?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "Kod pocztowy" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Zmień rozmiar nawigacji bocznej" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index 2246233a560..b0705f9b7c2 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Criar", + "description": "for adding new items" + }, "newUri": { "message": "Novo URI" }, @@ -470,7 +474,7 @@ "message": "Use campos ocultos para dados sensíveis como senhas" }, "checkBoxHelpText": { - "message": "Use caixas de seleção se gostaria de preencher automaticamente a caixa de seleção de um formulário, como um lembrar e-mail" + "message": "Use caixas de seleção se gostaria de preencher a caixa de seleção de um formulário, como um lembrar e-mail" }, "linkedHelpText": { "message": "Use um campo vinculado quando você estiver experienciando problemas com o preenchimento automático em um site específico." @@ -2411,6 +2415,10 @@ "message": "Tem certeza que quer apagar este Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copiar link do Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copiar link do Send para a área de transferência", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Envie informações sensíveis com segurança", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Nenhum resultado de busca" + }, "sendsBodyNoItems": { "message": "Compartilhe dados e arquivos com segurança com qualquer pessoa, em qualquer plataforma. Suas informações permanecerão criptografadas de ponta a ponta, limitando a exposição.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Limpe os filtros ou tente outro termo de busca" + }, "generatorNudgeTitle": { "message": "Crie senhas de forma rápida" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Desarquivar" }, + "archived": { + "message": "Arquivados" + }, "itemsInArchive": { "message": "Itens no arquivo" }, @@ -4313,6 +4330,21 @@ "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?" }, + "unArchiveAndSave": { + "message": "Desarquivar e salvar" + }, + "restartPremium": { + "message": "Retomar Premium" + }, + "premiumSubscriptionEnded": { + "message": "Sua assinatura Premium terminou" + }, + "premiumSubscriptionEndedDesc": { + "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." + }, + "itemRestored": { + "message": "O item foi restaurado" + }, "zipPostalCodeLabel": { "message": "CEP / Código postal" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Limite de tempo da sessão" }, + "resizeSideNavigation": { + "message": "Redimensionar navegação lateral" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Esta configuração é gerenciada pela sua organização." }, diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index d0e216df217..09037dd73b5 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Novo", + "description": "for adding new items" + }, "newUri": { "message": "Novo URI" }, @@ -2411,6 +2415,10 @@ "message": "Tem a certeza de que pretende eliminar este Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copiar link do Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copiar o link do Send para a área de transferência", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Envie informações sensíveis com segurança", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Não foram apresentados resultados de pesquisa" + }, "sendsBodyNoItems": { "message": "Partilhe ficheiros e dados de forma segura com qualquer pessoa, em qualquer plataforma. As suas informações permanecerão encriptadas ponto a ponto, limitando a exposição.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Limpe os filtros ou tente outro termo de pesquisa" + }, "generatorNudgeTitle": { "message": "Criar rapidamente palavras-passe" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Desarquivar" }, + "archived": { + "message": "Arquivado" + }, "itemsInArchive": { "message": "Itens no arquivo" }, @@ -4313,6 +4330,21 @@ "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?" }, + "unArchiveAndSave": { + "message": "Desarquivar e guardar" + }, + "restartPremium": { + "message": "Reiniciar Premium" + }, + "premiumSubscriptionEnded": { + "message": "A sua subscrição Premium terminou" + }, + "premiumSubscriptionEndedDesc": { + "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." + }, + "itemRestored": { + "message": "O item foi restaurado" + }, "zipPostalCodeLabel": { "message": "Código postal" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Tempo limite da sessão" }, + "resizeSideNavigation": { + "message": "Redimensionar navegação lateral" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Esta configuração é gerida pela sua organização." }, diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index 173cf6f5f5b..651c6386d7c 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "URI nou" }, @@ -2411,6 +2415,10 @@ "message": "Sigur doriți să ștergeți acest Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copiați link-ul Send-ului în clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index c0a838d86e2..d367051deaf 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Новый", + "description": "for adding new items" + }, "newUri": { "message": "Новый URI" }, @@ -2411,6 +2415,10 @@ "message": "Вы действительно хотите удалить эту Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Скопировать ссылку на Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Скопировать ссылку на Send в буфер обмена", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Безопасная отправка конфиденциальной информации", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Поиск не дал результатов" + }, "sendsBodyNoItems": { "message": "Безопасно обменивайтесь файлами и данными с кем угодно на любой платформе. Ваша информация надежно шифруется и доступ к ней ограничен.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Очистите фильтры или попробуйте другой поисковый запрос" + }, "generatorNudgeTitle": { "message": "Быстрое создание паролей" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Разархивировать" }, + "archived": { + "message": "Архивирован" + }, "itemsInArchive": { "message": "Элементы в архиве" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" }, + "unArchiveAndSave": { + "message": "Разархивировать и сохранить" + }, + "restartPremium": { + "message": "Переподключить Премиум" + }, + "premiumSubscriptionEnded": { + "message": "Ваша подписка Премиум закончилась" + }, + "premiumSubscriptionEndedDesc": { + "message": "Чтобы восстановить доступ к своему архиву, подключите подписку Премиум повторно. Если вы измените сведения об архивированном элементе перед переподключением, он будет перемещен обратно в ваше хранилище." + }, + "itemRestored": { + "message": "Элемент восстановлен" + }, "zipPostalCodeLabel": { "message": "Почтовый индекс" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Тайм-аут сессии" }, + "resizeSideNavigation": { + "message": "Изменить размер боковой навигации" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Эта настройка управляется вашей организацией." }, diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index d53bb073f49..982d73dd5c6 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "New URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index 23c3d3ae3d0..af256e6d883 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Nové", + "description": "for adding new items" + }, "newUri": { "message": "Nové URI" }, @@ -2411,6 +2415,10 @@ "message": "Naozaj chcete odstrániť tento Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Kopírovať odkaz na Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopírovať odkaz na Send do schránky", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send, citlivé informácie bezpečne", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Nenašli sa žiadne výsledky vyhľadávania" + }, "sendsBodyNoItems": { "message": "Bezpečne zdieľajte súbory a údaje s kýmkoľvek a na akejkoľvek platforme. Vaše informácie zostanú end-to-end zašifrované a zároveň sa obmedzí ich odhalenie.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Vymažte filtre alebo zmeňte vyhľadávaný výraz" + }, "generatorNudgeTitle": { "message": "Rýchle vytváranie hesiel" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Zrušiť archiváciu" }, + "archived": { + "message": "Archivované" + }, "itemsInArchive": { "message": "Položky v archíve" }, @@ -4313,6 +4330,21 @@ "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?" }, + "unArchiveAndSave": { + "message": "Zrušiť archiváciu a uložiť" + }, + "restartPremium": { + "message": "Reštartovať Prémium" + }, + "premiumSubscriptionEnded": { + "message": "Vaše predplatné Prémium skončilo" + }, + "premiumSubscriptionEndedDesc": { + "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." + }, + "itemRestored": { + "message": "Položka bola obnovená" + }, "zipPostalCodeLabel": { "message": "PSČ" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Časový limit relácie" }, + "resizeSideNavigation": { + "message": "Zmeniť veľkosť bočnej navigácie" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Toto nastavenie spravuje vaša organizácia." }, diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index b0acd957d68..3cb8df78bbc 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Nov URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index 0142a1bc719..dd9ce034124 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Нови УРЛ" }, @@ -2411,6 +2415,10 @@ "message": "Сигурно обрисати ово слање?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Копирај везу слања у привремену меморију", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Шаљите бзбедно осетљиве информације", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Делите датотеке и податке безбедно са било ким, на било којој платформи. Ваше информације ће остати шифроване од почетка-до-краја уз ограничење изложености.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Брзо креирајте лозинке" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Врати из архиве" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Ставке у архиви" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Архивиране ставке су искључене из општих резултата претраге и предлога за ауто попуњавање. Јесте ли сигурни да желите да архивирате ову ставку?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP/Поштански број" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Истек сесије" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Овим подешавањем управља ваша организација." }, diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index e19328075d1..ee60c850ce7 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Ny", + "description": "for adding new items" + }, "newUri": { "message": "Ny URI" }, @@ -2411,6 +2415,10 @@ "message": "Är du säker på att du vill radera denna Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Kopiera Send-länk", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Kopiera Send-länk till urklipp", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Skicka känslig information på ett säkert sätt", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Inga sökresultat returnerades" + }, "sendsBodyNoItems": { "message": "Dela filer och data på ett säkert sätt med vem som helst, på vilken plattform som helst. Din information kommer att förbli krypterad från början till slut samtidigt som exponeringen begränsas.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Töm filtren eller försök med en annan sökterm" + }, "generatorNudgeTitle": { "message": "Skapa lösenord snabbt" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Avarkivera" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Objekt i arkivet" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Arkiverade objekt är uteslutna från allmänna sökresultat och förslag för autofyll. Är du säker på att du vill arkivera detta objekt?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Starta om Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "Postnummer" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Sessionstidsgräns" }, + "resizeSideNavigation": { + "message": "Ändra storlek på sidnavigering" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Den här inställningen hanteras av din organisation." }, diff --git a/apps/desktop/src/locales/ta/messages.json b/apps/desktop/src/locales/ta/messages.json index 01f8f04f945..f64c237a7c3 100644 --- a/apps/desktop/src/locales/ta/messages.json +++ b/apps/desktop/src/locales/ta/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "புதிய URI" }, @@ -2411,6 +2415,10 @@ "message": "இந்த Send-ஐ நீங்கள் நீக்க விரும்புகிறீர்களா?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "கிளிப்போர்டுக்கு Send இணைப்பை நகலெடுக்கவும்", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "உணர்திறன் தகவல்களைப் பாதுகாப்பாக அனுப்பவும்", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "எந்தவொரு தளத்திலும், யாருடனும் கோப்புகளையும் தரவையும் பாதுகாப்பாகப் பகிரவும். உங்கள் தகவல் வெளிப்பாட்டைக் கட்டுப்படுத்தும்போது, அது முழுமையான குறியாக்கமாக இருக்கும்.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "கடவுச்சொற்களை விரைவாக உருவாக்கவும்" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index 2d0019a1f31..90eee288681 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "New URI" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index a9b08eda022..bc72ae0fc14 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "URL ใหม่" }, @@ -2411,6 +2415,10 @@ "message": "Are you sure you want to delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Copy Send link to clipboard", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Send sensitive information safely", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Quickly create passwords" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "ZIP / Postal code" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index fb867fba82c..3be00ac1393 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Yeni", + "description": "for adding new items" + }, "newUri": { "message": "Yeni URI" }, @@ -2411,6 +2415,10 @@ "message": "Bu Send'i silmek istediğinizden emin misiniz?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Send bağlantısını kopyala", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Send linkini panoya kopyala", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Hassas bilgileri güvenle paylaşın", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "Hiçbir sonuç bulunamadı" + }, "sendsBodyNoItems": { "message": "Dosyaları ve verileri istediğiniz kişilerle, istediğiniz platformda paylaşın. Bilgileriniz başkalarının eline geçmemesi için uçtan şifrelenecektir.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Filtreleri temizleyin veya başka bir arama yapmayı deneyin" + }, "generatorNudgeTitle": { "message": "Hızlıca parola oluşturun" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Arşivden çıkar" }, + "archived": { + "message": "Arşivlendi" + }, "itemsInArchive": { "message": "Arşivdeki kayıtlar" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Arşivlenmiş kayıtlar genel arama sonuçları ve otomatik doldurma önerilerinden hariç tutulur. Bu kaydı arşivlemek istediğinizden emin misiniz?" }, + "unArchiveAndSave": { + "message": "Arşivden çıkar ve kaydet" + }, + "restartPremium": { + "message": "Premium’u yeniden başlat" + }, + "premiumSubscriptionEnded": { + "message": "Premium aboneliğiniz sona erdi" + }, + "premiumSubscriptionEndedDesc": { + "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." + }, + "itemRestored": { + "message": "Kayıt geri yüklendi" + }, "zipPostalCodeLabel": { "message": "ZIP / posta kodu" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Oturum zaman aşımı" }, + "resizeSideNavigation": { + "message": "Kenar menüsünü yeniden boyutlandır" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Bu ayar kuruluşunuz tarafından yönetiliyor." }, diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index 9f86087f883..ebf6a74ff9b 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Новий URI" }, @@ -2411,6 +2415,10 @@ "message": "Ви дійсно хочете видалити це відправлення?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Копіювати посилання на відправлення", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Безпечно надсилайте конфіденційну інформацію", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Безпечно діліться файлами й даними з ким завгодно, на будь-якій платформі. Ваша інформація наскрізно зашифрована та має обмежений доступ.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Швидко створюйте паролі" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Видобути" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Записи в архіві" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "Архівовані записи виключаються з результатів звичайного пошуку та пропозицій автозаповнення. Ви дійсно хочете архівувати цей запис?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "Поштовий індекс" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Session timeout" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index 2c248671fbb..9ec16480d73 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "Đường dẫn mới" }, @@ -2411,6 +2415,10 @@ "message": "Bạn có chắc chắn muốn xóa Send này?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "Sao chép liên kết Send vào bảng nhớ tạm", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "Gửi thông tin nhạy cảm một cách an toàn", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "Chia sẻ tệp tin và dữ liệu một cách an toàn với bất kỳ ai, trên bất kỳ nền tảng nào. Thông tin của bạn sẽ được mã hóa đầu cuối để hạn chế rủi ro bị lộ.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "Tạo mật khẩu nhanh chóng" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Hủy lưu trữ" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Các mục trong kho lưu trữ" }, @@ -4313,6 +4330,21 @@ "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?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "Mã ZIP / Bưu điện" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Thời gian hết phiên" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "Cài đặt này do tổ chức của bạn quản lý." }, diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index b80e1cea689..c883192768e 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "新增", + "description": "for adding new items" + }, "newUri": { "message": "新增 URI" }, @@ -2411,6 +2415,10 @@ "message": "确定要删除此 Send 吗?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "复制 Send 链接", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "复制 Send 链接到剪贴板", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -3785,7 +3793,7 @@ "description": "Button text to navigate back" }, "removeItem": { - "message": "删除 $NAME$", + "message": "移除 $NAME$", "description": "Remove a selected option, such as a folder or collection", "placeholders": { "name": { @@ -4031,10 +4039,16 @@ "message": "安全地发送敏感信息", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "未返回搜索结果" + }, "sendsBodyNoItems": { "message": "在任何平台上安全地与任何人共享文件和数据。您的信息将在限制曝光的同时保持端到端加密。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "清除筛选或尝试其他搜索词" + }, "generatorNudgeTitle": { "message": "快速创建密码" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "取消归档" }, + "archived": { + "message": "已归档" + }, "itemsInArchive": { "message": "归档中的项目" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" }, + "unArchiveAndSave": { + "message": "取消归档并保存" + }, + "restartPremium": { + "message": "重启高级版" + }, + "premiumSubscriptionEnded": { + "message": "您的高级版订阅已结束" + }, + "premiumSubscriptionEndedDesc": { + "message": "要重新获取归档内容的访问权限,请重启您的高级版订阅。如果您在重启前编辑了某个已归档项目的详细信息,它将被移回您的密码库中。" + }, + "itemRestored": { + "message": "项目已恢复" + }, "zipPostalCodeLabel": { "message": "ZIP / 邮政编码" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "会话超时" }, + "resizeSideNavigation": { + "message": "调整侧边导航栏大小" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "此设置由您的组织管理。" }, diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index 7b5b352d5cb..17c7b0867ee 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "New", + "description": "for adding new items" + }, "newUri": { "message": "新增 URI" }, @@ -2411,6 +2415,10 @@ "message": "您確定要刪除此 Send 嗎?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "copySendLinkToClipboard": { "message": "複製 Send 連結到剪貼簿", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -4031,10 +4039,16 @@ "message": "安全傳送機密的資訊", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsTitleNoSearchResults": { + "message": "No search results returned" + }, "sendsBodyNoItems": { "message": "安全的和任何人及任何平臺分享檔案及資料。您的資料會受到端對端加密的保護。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Clear filters or try another search term" + }, "generatorNudgeTitle": { "message": "快速建立密碼" }, @@ -4292,6 +4306,9 @@ "unArchive": { "message": "取消封存" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "封存中的項目" }, @@ -4313,6 +4330,21 @@ "archiveItemConfirmDesc": { "message": "封存的項目將不會出現在一般搜尋結果或自動填入建議中。確定要封存此項目嗎?" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, + "restartPremium": { + "message": "Restart Premium" + }, + "premiumSubscriptionEnded": { + "message": "Your Premium subscription ended" + }, + "premiumSubscriptionEndedDesc": { + "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it’ll be moved back into your vault." + }, + "itemRestored": { + "message": "Item has been restored" + }, "zipPostalCodeLabel": { "message": "郵編 / 郵政代碼" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "工作階段逾時" }, + "resizeSideNavigation": { + "message": "調整側邊欄大小" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "此設定由您的組織管理。" }, diff --git a/apps/desktop/src/scss/migration.scss b/apps/desktop/src/scss/migration.scss index e3078158283..ba70d4fa009 100644 --- a/apps/desktop/src/scss/migration.scss +++ b/apps/desktop/src/scss/migration.scss @@ -13,3 +13,17 @@ bit-layout { padding: 0 0 0 0; } } +/** + * Send list panel styling for send-v2 component + * Temporary during migration - width handled by tw-w-2/5 + **/ +.vault > .send-items-panel { + order: 2; + min-width: 200px; + border-right: 1px solid; + + @include themify($themes) { + background-color: themed("backgroundColor"); + border-right-color: themed("borderColor"); + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts index 21ba7547f8b..a16ef93e230 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -34,7 +34,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getByIds } from "@bitwarden/common/platform/misc"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -158,7 +158,7 @@ export class VaultComponent cipherId: string | null = null; favorites = false; type: CipherType | null = null; - folderId: string | null = null; + folderId: string | null | undefined = null; collectionId: string | null = null; organizationId: string | null = null; myVaultOnly = false; @@ -980,9 +980,7 @@ export class VaultComponent // clear out organizationId when the user switches to a personal vault filter this.addOrganizationId = null; } - if (this.activeFilter.selectedFolderId && this.activeFilter.selectedFolder) { - this.folderId = this.activeFilter.selectedFolderId; - } + this.folderId = this.activeFilter.selectedFolderId; if (this.config == null) { return; @@ -990,7 +988,9 @@ export class VaultComponent this.config.initialValues = { ...this.config.initialValues, + folderId: this.folderId, organizationId: this.addOrganizationId as OrganizationId, + collectionIds: this.addCollectionIds as CollectionId[], }; } diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.html b/apps/desktop/src/vault/app/vault/item-footer.component.html index 859b2f1bdc5..a03f3e96b06 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.html +++ b/apps/desktop/src/vault/app/vault/item-footer.component.html @@ -7,9 +7,9 @@ [hidden]="action === 'view'" bitButton class="primary" - appA11yTitle="{{ 'save' | i18n }}" + appA11yTitle="{{ submitButtonText() }}" > - {{ "save" | i18n }} + {{ submitButtonText() }} diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts index 0ac12c928f2..c80e4e59ae4 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -8,6 +8,7 @@ import { ViewChild, OnChanges, SimpleChanges, + input, } from "@angular/core"; import { combineLatest, firstValueFrom, switchMap } from "rxjs"; @@ -67,6 +68,8 @@ export class ItemFooterComponent implements OnInit, OnChanges { // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null; + readonly submitButtonText = input(this.i18nService.t("save")); + activeUserId: UserId | null = null; passwordReprompted: boolean = false; @@ -218,7 +221,7 @@ export class ItemFooterComponent implements OnInit, OnChanges { } private async checkArchiveState() { - const cipherCanBeArchived = !this.cipher.isDeleted && this.cipher.organizationId == null; + const cipherCanBeArchived = !this.cipher.isDeleted; const [userCanArchive, hasArchiveFlagEnabled] = await firstValueFrom( this.accountService.activeAccount$.pipe( getUserId, diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.html b/apps/desktop/src/vault/app/vault/vault-items-v2.component.html index fc14700a7af..84c0cd8a1fb 100644 --- a/apps/desktop/src/vault/app/vault/vault-items-v2.component.html +++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.html @@ -2,6 +2,21 @@
+ @if (showPremiumCallout()) { +
+ } +
extends BaseVaultItemsComponent { + readonly showPremiumCallout = input(false); + readonly organizationId = input(undefined); + protected CipherViewLikeUtils = CipherViewLikeUtils; + constructor( searchService: SearchService, private readonly searchBarService: SearchBarService, @@ -37,6 +43,7 @@ export class VaultItemsV2Component extends BaseVaultIt accountService: AccountService, restrictedItemTypesService: RestrictedItemTypesService, configService: ConfigService, + private premiumUpgradePromptService: PremiumUpgradePromptService, ) { super(searchService, cipherService, accountService, restrictedItemTypesService, configService); @@ -47,6 +54,10 @@ export class VaultItemsV2Component extends BaseVaultIt }); } + async navigateToGetPremium() { + await this.premiumUpgradePromptService.promptForPremium(this.organizationId()); + } + trackByFn(index: number, c: C): string { return uuidAsString(c.id!); } diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.html b/apps/desktop/src/vault/app/vault/vault-v2.component.html index 2696dd0d452..d10b3fd85c6 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.html +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.html @@ -6,13 +6,15 @@ (onCipherClicked)="viewCipher($event)" (onCipherRightClicked)="viewCipherMenu($event)" (onAddCipher)="addCipher($event)" + [showPremiumCallout]="showPremiumCallout$ | async" + [organizationId]="organizationId" >
- + cipherId: string | null = null; favorites = false; type: CipherType | null = null; - folderId: string | null = null; + folderId: string | null | undefined = null; collectionId: string | null = null; - organizationId: string | null = null; + organizationId: OrganizationId | null = null; myVaultOnly = false; addType: CipherType | undefined = undefined; addOrganizationId: string | null = null; @@ -172,11 +183,25 @@ export class VaultV2Component deleted = false; userHasPremiumAccess = false; activeFilter: VaultFilter = new VaultFilter(); + private activeFilterSubject = new BehaviorSubject(new VaultFilter()); + private activeFilter$ = this.activeFilterSubject.asObservable(); + private userId$ = this.accountService.activeAccount$.pipe(getUserId); + showPremiumCallout$ = this.userId$.pipe( + switchMap((userId) => + combineLatest([ + this.activeFilter$, + this.cipherArchiveService.showSubscriptionEndedMessaging$(userId), + ]).pipe( + map(([activeFilter, showMessaging]) => activeFilter.status === "archive" && showMessaging), + ), + ), + ); activeUserId: UserId | null = null; cipherRepromptId: string | null = null; - cipher: CipherView | null = new CipherView(); + readonly cipher = signal(null); collections: CollectionView[] | null = null; config: CipherFormConfig | null = null; + readonly userHasPremium = signal(false); /** Tracks the disabled status of the edit cipher form */ protected formDisabled: boolean = false; @@ -187,12 +212,13 @@ export class VaultV2Component switchMap((id) => this.organizationService.organizations$(id)), ); - protected canAccessAttachments$ = this.accountService.activeAccount$.pipe( - filter((account): account is Account => !!account), - switchMap((account) => - this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), - ), - ); + protected readonly submitButtonText = computed(() => { + return this.cipher()?.isArchived && + !this.userHasPremium() && + this.cipherArchiveService.hasArchiveFlagEnabled$ + ? this.i18nService.t("unArchiveAndSave") + : this.i18nService.t("save"); + }); private componentIsDestroyed$ = new Subject(); private allOrganizations: Organization[] = []; @@ -223,11 +249,10 @@ export class VaultV2Component private collectionService: CollectionService, private organizationService: OrganizationService, private folderService: FolderService, - private configService: ConfigService, - private authRequestService: AuthRequestServiceAbstraction, private cipherArchiveService: CipherArchiveService, private policyService: PolicyService, private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, + private masterPasswordService: MasterPasswordServiceAbstraction, ) {} async ngOnInit() { @@ -241,6 +266,7 @@ export class VaultV2Component ) .subscribe((canAccessPremium: boolean) => { this.userHasPremiumAccess = canAccessPremium; + this.userHasPremium.set(canAccessPremium); }); this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { @@ -288,30 +314,40 @@ export class VaultV2Component this.showingModal = false; break; case "copyUsername": { - if (this.cipher?.login?.username) { - this.copyValue(this.cipher, this.cipher?.login?.username, "username", "Username"); + if (this.cipher()?.login?.username) { + this.copyValue( + this.cipher(), + this.cipher()?.login?.username, + "username", + "Username", + ); } break; } case "copyPassword": { - if (this.cipher?.login?.password && this.cipher.viewPassword) { - this.copyValue(this.cipher, this.cipher.login.password, "password", "Password"); + if (this.cipher()?.login?.password && this.cipher().viewPassword) { + this.copyValue( + this.cipher(), + this.cipher().login.password, + "password", + "Password", + ); await this.eventCollectionService - .collect(EventType.Cipher_ClientCopiedPassword, this.cipher.id) + .collect(EventType.Cipher_ClientCopiedPassword, this.cipher().id) .catch(() => {}); } break; } case "copyTotp": { if ( - this.cipher?.login?.hasTotp && - (this.cipher.organizationUseTotp || this.userHasPremiumAccess) + this.cipher()?.login?.hasTotp && + (this.cipher()?.organizationUseTotp || this.userHasPremiumAccess) ) { const value = await firstValueFrom( - this.totpService.getCode$(this.cipher.login.totp), + this.totpService.getCode$(this.cipher()?.login.totp), ).catch((): any => null); if (value) { - this.copyValue(this.cipher, value.code, "verificationCodeTotp", "TOTP"); + this.copyValue(this.cipher(), value.code, "verificationCodeTotp", "TOTP"); } } break; @@ -337,19 +373,12 @@ export class VaultV2Component this.searchBarService.setEnabled(true); this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); - const authRequests = await firstValueFrom( - this.authRequestService.getLatestPendingAuthRequest$()!, - ); - if (authRequests != null) { - this.messagingService.send("openLoginApproval", { - notificationId: authRequests.id, - }); - } - this.activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getUserId), ).catch((): any => null); + await this.sendOpenLoginApprovalMessage(this.activeUserId); + if (this.activeUserId) { this.cipherService .failedToDecryptCiphers$(this.activeUserId) @@ -416,6 +445,7 @@ export class VaultV2Component selectedOrganizationId: params.selectedOrganizationId, myVaultOnly: params.myVaultOnly ?? false, }); + this.activeFilterSubject.next(this.activeFilter); if (this.vaultItemsComponent) { await this.vaultItemsComponent.reload(this.activeFilter.buildFilter()).catch(() => {}); } @@ -440,7 +470,7 @@ export class VaultV2Component return; } this.cipherId = cipher.id; - this.cipher = cipher; + this.cipher.set(cipher); this.collections = this.vaultFilterComponent?.collections?.fullList.filter((c) => cipher.collectionIds.includes(c.id), @@ -679,7 +709,7 @@ export class VaultV2Component return; } this.cipherId = cipher.id; - this.cipher = cipher; + this.cipher.set(cipher); await this.buildFormConfig("edit"); if (!cipher.edit && this.config) { this.config.mode = "partial-edit"; @@ -693,7 +723,7 @@ export class VaultV2Component return; } this.cipherId = cipher.id; - this.cipher = cipher; + this.cipher.set(cipher); await this.buildFormConfig("clone"); this.action = "clone"; await this.go().catch(() => {}); @@ -742,7 +772,7 @@ export class VaultV2Component return; } this.addType = type || this.activeFilter.cipherType; - this.cipher = new CipherView(); + this.cipher.set(new CipherView()); this.cipherId = null; await this.buildFormConfig("add"); this.action = "add"; @@ -774,14 +804,14 @@ export class VaultV2Component ); this.cipherId = cipher.id; - this.cipher = cipher; + this.cipher.set(cipher); await this.go().catch(() => {}); await this.vaultItemsComponent?.refresh().catch(() => {}); } async deleteCipher() { this.cipherId = null; - this.cipher = null; + this.cipher.set(null); this.action = null; await this.go().catch(() => {}); await this.vaultItemsComponent?.refresh().catch(() => {}); @@ -796,7 +826,7 @@ export class VaultV2Component async cancelCipher(cipher: CipherView) { this.cipherId = cipher.id; - this.cipher = cipher; + this.cipher.set(cipher); this.action = this.cipherId ? "view" : null; await this.go().catch(() => {}); } @@ -806,6 +836,7 @@ export class VaultV2Component this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)), ); this.activeFilter = vaultFilter; + this.activeFilterSubject.next(vaultFilter); await this.vaultItemsComponent ?.reload( this.activeFilter.buildFilter(), @@ -887,14 +918,16 @@ export class VaultV2Component /** Refresh the current cipher object */ protected async refreshCurrentCipher() { - if (!this.cipher) { + if (!this.cipher()) { return; } - this.cipher = await firstValueFrom( - this.cipherService.cipherViews$(this.activeUserId!).pipe( - filter((c) => !!c), - map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null), + this.cipher.set( + await firstValueFrom( + this.cipherService.cipherViews$(this.activeUserId!).pipe( + filter((c) => !!c), + map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null), + ), ), ); } @@ -983,9 +1016,7 @@ export class VaultV2Component // clear out organizationId when the user switches to a personal vault filter this.addOrganizationId = null; } - if (this.activeFilter.selectedFolderId && this.activeFilter.selectedFolder) { - this.folderId = this.activeFilter.selectedFolderId; - } + this.folderId = this.activeFilter.selectedFolderId; if (this.config == null) { return; @@ -994,6 +1025,8 @@ export class VaultV2Component this.config.initialValues = { ...this.config.initialValues, organizationId: this.addOrganizationId as OrganizationId, + folderId: this.folderId, + collectionIds: this.addCollectionIds as CollectionId[], }; } @@ -1020,4 +1053,27 @@ export class VaultV2Component } return repromptResult; } + + /** + * Sends a message that will retrieve any pending auth requests from the server. If there are + * pending auth requests for this user, a LoginApprovalDialogComponent will open. If there + * are no pending auth requests, nothing happens (see AppComponent: "openLoginApproval"). + */ + private async sendOpenLoginApprovalMessage(activeUserId: UserId) { + // This is a defensive check against a race condition where a user may have successfully logged + // in with no forceSetPasswordReason, but while the vault component is loading, a sync sets + // forceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission (see DefaultSyncService). + // This could potentially happen if an Admin upgrades the user's permissions as the user is logging + // in. In this rare case we do not want to send an "openLoginApproval" message. + // + // This also keeps parity with other usages of the "openLoginApproval" message. That is: don't send + // an "openLoginApproval" message if the user is required to set/change their password. + const forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(activeUserId), + ); + if (forceSetPasswordReason === ForceSetPasswordReason.None) { + // If there are pending auth requests for this user, a LoginApprovalDialogComponent will open + this.messagingService.send("openLoginApproval"); + } + } } diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.service.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.service.ts index dc05248d7ba..f4b6f41fab6 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.service.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.service.ts @@ -5,7 +5,6 @@ import { CollectionAdminView, CollectionService } from "@bitwarden/admin-console 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -35,7 +34,6 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest stateProvider: StateProvider, collectionService: CollectionService, accountService: AccountService, - configService: ConfigService, ) { super( organizationService, @@ -46,7 +44,6 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest stateProvider, collectionService, accountService, - configService, ); } diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts index 3a624e11d95..c95f94b1e11 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm-dialog.component.ts @@ -6,7 +6,6 @@ import { firstValueFrom, map, Observable, switchMap } from "rxjs"; import { OrganizationUserApiService, - OrganizationUserBulkConfirmRequest, OrganizationUserBulkPublicKeyResponse, OrganizationUserBulkResponse, OrganizationUserService, @@ -15,10 +14,8 @@ import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enum import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProviderUserBulkPublicKeyResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk-public-key.response"; import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -54,7 +51,6 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { protected i18nService: I18nService, private stateProvider: StateProvider, private organizationUserService: OrganizationUserService, - private configService: ConfigService, ) { super(keyService, encryptService, i18nService); @@ -84,19 +80,9 @@ export class BulkConfirmDialogComponent extends BaseBulkConfirmComponent { protected postConfirmRequest = async ( userIdsWithKeys: { id: string; key: string }[], ): Promise> => { - if ( - await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)) - ) { - return await firstValueFrom( - this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys), - ); - } else { - const request = new OrganizationUserBulkConfirmRequest(userIdsWithKeys); - return await this.organizationUserApiService.postOrganizationUserBulkConfirm( - this.organization.id, - request, - ); - } + return await firstValueFrom( + this.organizationUserService.bulkConfirmUsers(this.organization, userIdsWithKeys), + ); }; static open(dialogService: DialogService, config: DialogConfig) { diff --git a/apps/web/src/app/admin-console/organizations/members/index.ts b/apps/web/src/app/admin-console/organizations/members/index.ts index 95bd8baf7c7..7026b9bb6ac 100644 --- a/apps/web/src/app/admin-console/organizations/members/index.ts +++ b/apps/web/src/app/admin-console/organizations/members/index.ts @@ -1 +1,2 @@ export * from "./members.module"; +export * from "./pipes"; diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.html b/apps/web/src/app/admin-console/organizations/members/members.component.html index 84e5c33d20d..921004e315d 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.html +++ b/apps/web/src/app/admin-console/organizations/members/members.component.html @@ -102,15 +102,25 @@ {{ (organization.useGroups ? "groups" : "collections") | i18n }} {{ "role" | i18n }} {{ "policies" | i18n }} - - + +
+ + +
@@ -352,13 +362,16 @@ - +
+
+ +
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 51a2a6dafc0..e57cf54c180 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 @@ -35,6 +35,7 @@ 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"; @@ -55,7 +56,11 @@ import { OrganizationUserView } from "../core/views/organization-user.view"; import { AccountRecoveryDialogResultType } from "./components/account-recovery/account-recovery-dialog.component"; import { MemberDialogResult, MemberDialogTab } from "./components/member-dialog"; -import { MemberDialogManagerService, OrganizationMembersService } from "./services"; +import { + MemberDialogManagerService, + MemberExportService, + OrganizationMembersService, +} from "./services"; import { DeleteManagedMemberWarningService } from "./services/delete-managed-member/delete-managed-member-warning.service"; import { MemberActionsService, @@ -119,6 +124,8 @@ export class MembersComponent extends BaseMembersComponent private policyService: PolicyService, private policyApiService: PolicyApiServiceAbstraction, private organizationMetadataService: OrganizationMetadataServiceAbstraction, + private memberExportService: MemberExportService, + private fileDownloadService: FileDownloadService, private configService: ConfigService, private environmentService: EnvironmentService, ) { @@ -593,4 +600,36 @@ export class MembersComponent extends BaseMembersComponent .getCheckedUsers() .every((member) => member.managedByOrganization && validStatuses.includes(member.status)); } + + 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" }, + }); + + 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}`); + } + }; } 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 3b233932ed3..65625cfd247 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 @@ -19,10 +19,12 @@ import { BulkStatusComponent } from "./components/bulk/bulk-status.component"; import { UserDialogModule } from "./components/member-dialog"; import { MembersRoutingModule } from "./members-routing.module"; import { MembersComponent } from "./members.component"; +import { UserStatusPipe } from "./pipes"; import { OrganizationMembersService, MemberActionsService, MemberDialogManagerService, + MemberExportService, } from "./services"; @NgModule({ @@ -45,12 +47,15 @@ import { BulkStatusComponent, MembersComponent, BulkDeleteDialogComponent, + UserStatusPipe, ], providers: [ OrganizationMembersService, MemberActionsService, BillingConstraintService, MemberDialogManagerService, + MemberExportService, + UserStatusPipe, ], }) export class MembersModule {} diff --git a/apps/web/src/app/admin-console/organizations/members/pipes/index.ts b/apps/web/src/app/admin-console/organizations/members/pipes/index.ts new file mode 100644 index 00000000000..67c485ed361 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/pipes/index.ts @@ -0,0 +1 @@ +export * from "./user-status.pipe"; diff --git a/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.spec.ts b/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.spec.ts new file mode 100644 index 00000000000..3fd05c8a2e8 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.spec.ts @@ -0,0 +1,47 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { UserStatusPipe } from "./user-status.pipe"; + +describe("UserStatusPipe", () => { + let pipe: UserStatusPipe; + let i18nService: MockProxy; + + beforeEach(() => { + i18nService = mock(); + i18nService.t.mockImplementation((key: string) => key); + pipe = new UserStatusPipe(i18nService); + }); + + it("transforms OrganizationUserStatusType.Invited to 'invited'", () => { + expect(pipe.transform(OrganizationUserStatusType.Invited)).toBe("invited"); + expect(i18nService.t).toHaveBeenCalledWith("invited"); + }); + + it("transforms OrganizationUserStatusType.Accepted to 'accepted'", () => { + expect(pipe.transform(OrganizationUserStatusType.Accepted)).toBe("accepted"); + expect(i18nService.t).toHaveBeenCalledWith("accepted"); + }); + + it("transforms OrganizationUserStatusType.Confirmed to 'confirmed'", () => { + expect(pipe.transform(OrganizationUserStatusType.Confirmed)).toBe("confirmed"); + expect(i18nService.t).toHaveBeenCalledWith("confirmed"); + }); + + it("transforms OrganizationUserStatusType.Revoked to 'revoked'", () => { + expect(pipe.transform(OrganizationUserStatusType.Revoked)).toBe("revoked"); + expect(i18nService.t).toHaveBeenCalledWith("revoked"); + }); + + it("transforms null to 'unknown'", () => { + expect(pipe.transform(null)).toBe("unknown"); + expect(i18nService.t).toHaveBeenCalledWith("unknown"); + }); + + it("transforms undefined to 'unknown'", () => { + expect(pipe.transform(undefined)).toBe("unknown"); + expect(i18nService.t).toHaveBeenCalledWith("unknown"); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.ts b/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.ts new file mode 100644 index 00000000000..81590616027 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/pipes/user-status.pipe.ts @@ -0,0 +1,30 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +@Pipe({ + name: "userStatus", + standalone: false, +}) +export class UserStatusPipe implements PipeTransform { + constructor(private i18nService: I18nService) {} + + transform(value?: OrganizationUserStatusType): string { + if (value == null) { + return this.i18nService.t("unknown"); + } + switch (value) { + case OrganizationUserStatusType.Invited: + return this.i18nService.t("invited"); + case OrganizationUserStatusType.Accepted: + return this.i18nService.t("accepted"); + case OrganizationUserStatusType.Confirmed: + return this.i18nService.t("confirmed"); + case OrganizationUserStatusType.Revoked: + return this.i18nService.t("revoked"); + default: + return this.i18nService.t("unknown"); + } + } +} diff --git a/apps/web/src/app/admin-console/organizations/members/services/index.ts b/apps/web/src/app/admin-console/organizations/members/services/index.ts index baaa33eeae9..fd6b5816513 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/index.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/index.ts @@ -1,4 +1,5 @@ export { OrganizationMembersService } from "./organization-members-service/organization-members.service"; export { MemberActionsService } from "./member-actions/member-actions.service"; export { MemberDialogManagerService } from "./member-dialog-manager/member-dialog-manager.service"; +export { MemberExportService } from "./member-export"; export { DeleteManagedMemberWarningService } from "./delete-managed-member/delete-managed-member-warning.service"; 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 80a330b0db1..1dd75a79180 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 @@ -12,15 +12,10 @@ import { } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { OrgKey } from "@bitwarden/common/types/key"; import { newGuid } from "@bitwarden/guid"; -import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; @@ -30,13 +25,9 @@ describe("MemberActionsService", () => { let service: MemberActionsService; let organizationUserApiService: MockProxy; let organizationUserService: MockProxy; - let keyService: MockProxy; - let encryptService: MockProxy; let configService: MockProxy; - let accountService: FakeAccountService; let organizationMetadataService: MockProxy; - const userId = newGuid() as UserId; const organizationId = newGuid() as OrganizationId; const userIdToManage = newGuid(); @@ -46,10 +37,7 @@ describe("MemberActionsService", () => { beforeEach(() => { organizationUserApiService = mock(); organizationUserService = mock(); - keyService = mock(); - encryptService = mock(); configService = mock(); - accountService = mockAccountServiceWith(userId); organizationMetadataService = mock(); mockOrganization = { @@ -71,10 +59,7 @@ describe("MemberActionsService", () => { service = new MemberActionsService( organizationUserApiService, organizationUserService, - keyService, - encryptService, configService, - accountService, organizationMetadataService, ); }); @@ -242,8 +227,7 @@ describe("MemberActionsService", () => { describe("confirmUser", () => { const publicKey = new Uint8Array([1, 2, 3, 4, 5]); - it("should confirm user using new flow when feature flag is enabled", async () => { - configService.getFeatureFlag$.mockReturnValue(of(true)); + it("should confirm user", async () => { organizationUserService.confirmUser.mockReturnValue(of(undefined)); const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization); @@ -257,44 +241,7 @@ describe("MemberActionsService", () => { expect(organizationUserApiService.postOrganizationUserConfirm).not.toHaveBeenCalled(); }); - it("should confirm user using exising flow when feature flag is disabled", async () => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - - const mockOrgKey = mock(); - const mockOrgKeys = { [organizationId]: mockOrgKey }; - keyService.orgKeys$.mockReturnValue(of(mockOrgKeys)); - - const mockEncryptedKey = new EncString("encrypted-key-data"); - encryptService.encapsulateKeyUnsigned.mockResolvedValue(mockEncryptedKey); - - organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined); - - const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization); - - expect(result).toEqual({ success: true }); - expect(keyService.orgKeys$).toHaveBeenCalledWith(userId); - expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(mockOrgKey, publicKey); - expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith( - organizationId, - userIdToManage, - expect.objectContaining({ - key: "encrypted-key-data", - }), - ); - }); - - it("should handle missing organization keys", async () => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - keyService.orgKeys$.mockReturnValue(of({})); - - const result = await service.confirmUser(mockOrgUser, publicKey, mockOrganization); - - expect(result.success).toBe(false); - expect(result.error).toContain("Organization keys not found"); - }); - it("should handle confirm errors", async () => { - configService.getFeatureFlag$.mockReturnValue(of(true)); const errorMessage = "Confirm failed"; organizationUserService.confirmUser.mockImplementation(() => { throw new Error(errorMessage); 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 f3774e3cb25..a44bfa4b19c 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,10 +1,9 @@ import { Injectable } from "@angular/core"; -import { firstValueFrom, switchMap, map } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { OrganizationUserApiService, OrganizationUserBulkResponse, - OrganizationUserConfirmRequest, OrganizationUserService, } from "@bitwarden/admin-console/common"; import { @@ -12,14 +11,10 @@ import { OrganizationUserStatusType, } 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 { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -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 { KeyService } from "@bitwarden/key-management"; import { UserId } from "@bitwarden/user-core"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; @@ -38,15 +33,10 @@ export interface BulkActionResult { @Injectable() export class MemberActionsService { - private userId$ = this.accountService.activeAccount$.pipe(getUserId); - constructor( private organizationUserApiService: OrganizationUserApiService, private organizationUserService: OrganizationUserService, - private keyService: KeyService, - private encryptService: EncryptService, private configService: ConfigService, - private accountService: AccountService, private organizationMetadataService: OrganizationMetadataServiceAbstraction, ) {} @@ -128,37 +118,9 @@ export class MemberActionsService { organization: Organization, ): Promise { try { - if ( - await firstValueFrom(this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)) - ) { - await firstValueFrom( - this.organizationUserService.confirmUser(organization, user.id, publicKey), - ); - } else { - const request = await firstValueFrom( - this.userId$.pipe( - switchMap((userId) => this.keyService.orgKeys$(userId)), - map((orgKeys) => { - if (orgKeys == null || orgKeys[organization.id] == null) { - throw new Error("Organization keys not found for provided User."); - } - return orgKeys[organization.id]; - }), - switchMap((orgKey) => this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey)), - map((encKey) => { - const req = new OrganizationUserConfirmRequest(); - req.key = encKey.encryptedString; - return req; - }), - ), - ); - - await this.organizationUserApiService.postOrganizationUserConfirm( - organization.id, - user.id, - request, - ); - } + await firstValueFrom( + this.organizationUserService.confirmUser(organization, user.id, publicKey), + ); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message ?? String(error) }; diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-export/index.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/index.ts new file mode 100644 index 00000000000..acd36a91683 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/index.ts @@ -0,0 +1,2 @@ +export * from "./member.export"; +export * from "./member-export.service"; 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 new file mode 100644 index 00000000000..1e229b95d24 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.spec.ts @@ -0,0 +1,151 @@ +import { TestBed } from "@angular/core/testing"; +import { MockProxy, mock } from "jest-mock-extended"; + +import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe"; +import { + OrganizationUserStatusType, + OrganizationUserType, +} from "@bitwarden/common/admin-console/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { OrganizationUserView } from "../../../core"; +import { UserStatusPipe } from "../../pipes"; + +import { MemberExportService } from "./member-export.service"; + +describe("MemberExportService", () => { + let service: MemberExportService; + let i18nService: MockProxy; + + beforeEach(() => { + i18nService = mock(); + + // Setup common i18n translations + i18nService.t.mockImplementation((key: string) => { + const translations: Record = { + // Column headers + email: "Email", + name: "Name", + status: "Status", + role: "Role", + twoStepLogin: "Two-step Login", + accountRecovery: "Account Recovery", + secretsManager: "Secrets Manager", + groups: "Groups", + // Status values + invited: "Invited", + accepted: "Accepted", + confirmed: "Confirmed", + revoked: "Revoked", + // Role values + owner: "Owner", + admin: "Admin", + user: "User", + custom: "Custom", + // Boolean states + enabled: "Enabled", + disabled: "Disabled", + enrolled: "Enrolled", + notEnrolled: "Not Enrolled", + }; + return translations[key] || key; + }); + + TestBed.configureTestingModule({ + providers: [ + MemberExportService, + { provide: I18nService, useValue: i18nService }, + UserTypePipe, + UserStatusPipe, + ], + }); + + service = TestBed.inject(MemberExportService); + }); + + describe("getMemberExport", () => { + it("should export members with all fields populated", () => { + const members: OrganizationUserView[] = [ + { + email: "user1@example.com", + name: "User One", + status: OrganizationUserStatusType.Confirmed, + type: OrganizationUserType.Admin, + twoFactorEnabled: true, + resetPasswordEnrolled: true, + accessSecretsManager: true, + groupNames: ["Group A", "Group B"], + } as OrganizationUserView, + { + email: "user2@example.com", + name: "User Two", + status: OrganizationUserStatusType.Invited, + type: OrganizationUserType.User, + twoFactorEnabled: false, + resetPasswordEnrolled: false, + accessSecretsManager: false, + groupNames: ["Group C"], + } as OrganizationUserView, + ]; + + const csvData = service.getMemberExport(members); + + expect(csvData).toContain("Email,Name,Status,Role,Two-step Login,Account Recovery"); + expect(csvData).toContain("user1@example.com"); + expect(csvData).toContain("User One"); + expect(csvData).toContain("Confirmed"); + expect(csvData).toContain("Admin"); + expect(csvData).toContain("user2@example.com"); + expect(csvData).toContain("User Two"); + expect(csvData).toContain("Invited"); + }); + + it("should handle members with null name", () => { + const members: OrganizationUserView[] = [ + { + email: "user@example.com", + name: null, + status: OrganizationUserStatusType.Confirmed, + type: OrganizationUserType.User, + twoFactorEnabled: false, + resetPasswordEnrolled: false, + accessSecretsManager: false, + groupNames: [], + } as OrganizationUserView, + ]; + + const csvData = service.getMemberExport(members); + + expect(csvData).toContain("user@example.com"); + // Empty name is represented as an empty field in CSV + expect(csvData).toContain("user@example.com,,Confirmed"); + }); + + it("should handle members with no groups", () => { + const members: OrganizationUserView[] = [ + { + email: "user@example.com", + name: "User", + status: OrganizationUserStatusType.Confirmed, + type: OrganizationUserType.User, + twoFactorEnabled: false, + resetPasswordEnrolled: false, + accessSecretsManager: false, + groupNames: null, + } as OrganizationUserView, + ]; + + const csvData = service.getMemberExport(members); + + expect(csvData).toContain("user@example.com"); + expect(csvData).toBeDefined(); + }); + + it("should handle empty members array", () => { + const csvData = service.getMemberExport([]); + + // When array is empty, papaparse returns an empty string + expect(csvData).toBe(""); + }); + }); +}); 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 new file mode 100644 index 00000000000..c00881617a4 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member-export.service.ts @@ -0,0 +1,49 @@ +import { inject, Injectable } from "@angular/core"; +import * as papa from "papaparse"; + +import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ExportHelper } from "@bitwarden/vault-export-core"; + +import { OrganizationUserView } from "../../../core"; +import { UserStatusPipe } from "../../pipes"; + +import { MemberExport } from "./member.export"; + +@Injectable() +export class MemberExportService { + private i18nService = inject(I18nService); + private userTypePipe = inject(UserTypePipe); + private userStatusPipe = inject(UserStatusPipe); + + getMemberExport(members: OrganizationUserView[]): string { + const exportData = members.map((m) => + MemberExport.fromOrganizationUserView( + this.i18nService, + this.userTypePipe, + this.userStatusPipe, + m, + ), + ); + + 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"), + ]; + + return papa.unparse(exportData, { + columns: headers, + header: true, + }); + } + + 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/member-export/member.export.ts b/apps/web/src/app/admin-console/organizations/members/services/member-export/member.export.ts new file mode 100644 index 00000000000..262e8ebd9fb --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/members/services/member-export/member.export.ts @@ -0,0 +1,43 @@ +import { UserTypePipe } from "@bitwarden/angular/pipes/user-type.pipe"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { OrganizationUserView } from "../../../core"; +import { UserStatusPipe } from "../../pipes"; + +export class MemberExport { + /** + * @param user Organization user to export + * @returns a Record of each column header key, value + * All property members must be a string for export purposes. Null and undefined will appear as + * "null" in a .csv export, therefore an empty string is preferable to a nullish type. + */ + static fromOrganizationUserView( + i18nService: I18nService, + userTypePipe: UserTypePipe, + userStatusPipe: UserStatusPipe, + user: OrganizationUserView, + ): Record { + const result = { + [i18nService.t("email")]: user.email, + [i18nService.t("name")]: user.name ?? "", + [i18nService.t("status")]: userStatusPipe.transform(user.status), + [i18nService.t("role")]: userTypePipe.transform(user.type), + + [i18nService.t("twoStepLogin")]: user.twoFactorEnabled + ? i18nService.t("optionEnabled") + : i18nService.t("disabled"), + + [i18nService.t("accountRecovery")]: user.resetPasswordEnrolled + ? i18nService.t("enrolled") + : i18nService.t("notEnrolled"), + + [i18nService.t("secretsManager")]: user.accessSecretsManager + ? i18nService.t("optionEnabled") + : i18nService.t("disabled"), + + [i18nService.t("groups")]: user.groupNames?.join(", ") ?? "", + }; + + return result; + } +} 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/auto-confirm-edit-policy-dialog.component.ts index 99d484f04f2..9dfb8ebb7e7 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/auto-confirm-edit-policy-dialog.component.ts @@ -22,7 +22,7 @@ import { tap, } from "rxjs"; -import { AutomaticUserConfirmationService } from "@bitwarden/admin-console/common"; +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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"; @@ -30,7 +30,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { getById } from "@bitwarden/common/platform/misc"; import { @@ -115,7 +114,6 @@ export class AutoConfirmPolicyDialogComponent formBuilder: FormBuilder, dialogRef: DialogRef, toastService: ToastService, - configService: ConfigService, keyService: KeyService, private organizationService: OrganizationService, private policyService: PolicyService, @@ -131,7 +129,6 @@ export class AutoConfirmPolicyDialogComponent formBuilder, dialogRef, toastService, - configService, keyService, ); diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.spec.ts index 0e025a9d52a..125876ce05a 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.spec.ts @@ -188,7 +188,7 @@ describe("PoliciesComponent", () => { }); describe("orgPolicies$", () => { - it("should fetch policies from API for current organization", async () => { + describe("with multiple policies", () => { const mockPolicyResponsesData = [ { id: newGuid(), @@ -206,39 +206,63 @@ describe("PoliciesComponent", () => { }, ]; - const listResponse = new ListResponse( - { Data: mockPolicyResponsesData, ContinuationToken: null }, - PolicyResponse, - ); + beforeEach(async () => { + const listResponse = new ListResponse( + { Data: mockPolicyResponsesData, ContinuationToken: null }, + PolicyResponse, + ); - mockPolicyApiService.getPolicies.mockResolvedValue(listResponse); + mockPolicyApiService.getPolicies.mockResolvedValue(listResponse); - const policies = await firstValueFrom(component["orgPolicies$"]); - expect(policies).toEqual(listResponse.data); - expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId); + fixture = TestBed.createComponent(PoliciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should fetch policies from API for current organization", async () => { + const policies = await firstValueFrom(component["orgPolicies$"]); + expect(policies.length).toBe(2); + expect(mockPolicyApiService.getPolicies).toHaveBeenCalledWith(mockOrgId); + }); }); - it("should return empty array when API returns no data", async () => { - mockPolicyApiService.getPolicies.mockResolvedValue( - new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse), - ); + describe("with no policies", () => { + beforeEach(async () => { + mockPolicyApiService.getPolicies.mockResolvedValue( + new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse), + ); - const policies = await firstValueFrom(component["orgPolicies$"]); - expect(policies).toEqual([]); + fixture = TestBed.createComponent(PoliciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should return empty array when API returns no data", async () => { + const policies = await firstValueFrom(component["orgPolicies$"]); + expect(policies).toEqual([]); + }); }); - it("should return empty array when API returns null data", async () => { - mockPolicyApiService.getPolicies.mockResolvedValue( - new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse), - ); + describe("with null data", () => { + beforeEach(async () => { + mockPolicyApiService.getPolicies.mockResolvedValue( + new ListResponse({ Data: null, ContinuationToken: null }, PolicyResponse), + ); - const policies = await firstValueFrom(component["orgPolicies$"]); - expect(policies).toEqual([]); + fixture = TestBed.createComponent(PoliciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should return empty array when API returns null data", async () => { + const policies = await firstValueFrom(component["orgPolicies$"]); + expect(policies).toEqual([]); + }); }); }); describe("policiesEnabledMap$", () => { - it("should create a map of policy types to their enabled status", async () => { + describe("with multiple policies", () => { const mockPolicyResponsesData = [ { id: "policy-1", @@ -263,27 +287,43 @@ describe("PoliciesComponent", () => { }, ]; - mockPolicyApiService.getPolicies.mockResolvedValue( - new ListResponse( - { Data: mockPolicyResponsesData, ContinuationToken: null }, - PolicyResponse, - ), - ); + beforeEach(async () => { + mockPolicyApiService.getPolicies.mockResolvedValue( + new ListResponse( + { Data: mockPolicyResponsesData, ContinuationToken: null }, + PolicyResponse, + ), + ); - const map = await firstValueFrom(component.policiesEnabledMap$); - expect(map.size).toBe(3); - expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true); - expect(map.get(PolicyType.RequireSso)).toBe(false); - expect(map.get(PolicyType.SingleOrg)).toBe(true); + fixture = TestBed.createComponent(PoliciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create a map of policy types to their enabled status", async () => { + const map = await firstValueFrom(component.policiesEnabledMap$); + expect(map.size).toBe(3); + expect(map.get(PolicyType.TwoFactorAuthentication)).toBe(true); + expect(map.get(PolicyType.RequireSso)).toBe(false); + expect(map.get(PolicyType.SingleOrg)).toBe(true); + }); }); - it("should create empty map when no policies exist", async () => { - mockPolicyApiService.getPolicies.mockResolvedValue( - new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse), - ); + describe("with no policies", () => { + beforeEach(async () => { + mockPolicyApiService.getPolicies.mockResolvedValue( + new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse), + ); - const map = await firstValueFrom(component.policiesEnabledMap$); - expect(map.size).toBe(0); + fixture = TestBed.createComponent(PoliciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create empty map when no policies exist", async () => { + const map = await firstValueFrom(component.policiesEnabledMap$); + expect(map.size).toBe(0); + }); }); }); @@ -292,31 +332,36 @@ describe("PoliciesComponent", () => { expect(mockPolicyService.policies$).toHaveBeenCalledWith(mockUserId); }); - it("should refresh policies when policyService emits", async () => { - const policiesSubject = new BehaviorSubject([]); - mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable()); + describe("when policyService emits", () => { + let policiesSubject: BehaviorSubject; + let callCount: number; - let callCount = 0; - mockPolicyApiService.getPolicies.mockImplementation(() => { - callCount++; - return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse)); + beforeEach(async () => { + policiesSubject = new BehaviorSubject([]); + mockPolicyService.policies$.mockReturnValue(policiesSubject.asObservable()); + + callCount = 0; + mockPolicyApiService.getPolicies.mockImplementation(() => { + callCount++; + return of(new ListResponse({ Data: [], ContinuationToken: null }, PolicyResponse)); + }); + + fixture = TestBed.createComponent(PoliciesComponent); + fixture.detectChanges(); }); - const newFixture = TestBed.createComponent(PoliciesComponent); - newFixture.detectChanges(); + it("should refresh policies when policyService emits", () => { + const initialCallCount = callCount; - const initialCallCount = callCount; + policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]); - policiesSubject.next([{ type: PolicyType.TwoFactorAuthentication }]); - - expect(callCount).toBeGreaterThan(initialCallCount); - - newFixture.destroy(); + expect(callCount).toBeGreaterThan(initialCallCount); + }); }); }); describe("handleLaunchEvent", () => { - it("should open policy dialog when policyId is in query params", async () => { + describe("when policyId is in query params", () => { const mockPolicyId = newGuid(); const mockPolicy: BasePolicyEditDefinition = { name: "Test Policy", @@ -335,54 +380,59 @@ describe("PoliciesComponent", () => { data: null, }; - queryParamsSubject.next({ policyId: mockPolicyId }); + let dialogOpenSpy: jest.SpyInstance; - mockPolicyApiService.getPolicies.mockReturnValue( - of( - new ListResponse( - { Data: [mockPolicyResponseData], ContinuationToken: null }, - PolicyResponse, + beforeEach(async () => { + queryParamsSubject.next({ policyId: mockPolicyId }); + + mockPolicyApiService.getPolicies.mockReturnValue( + of( + new ListResponse( + { Data: [mockPolicyResponseData], ContinuationToken: null }, + PolicyResponse, + ), ), - ), - ); + ); - const dialogOpenSpy = jest - .spyOn(PolicyEditDialogComponent, "open") - .mockReturnValue({ close: jest.fn() } as any); + dialogOpenSpy = jest + .spyOn(PolicyEditDialogComponent, "open") + .mockReturnValue({ close: jest.fn() } as any); - TestBed.resetTestingModule(); - await TestBed.configureTestingModule({ - imports: [PoliciesComponent], - providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: OrganizationService, useValue: mockOrganizationService }, - { provide: AccountService, useValue: mockAccountService }, - { provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService }, - { provide: PolicyListService, useValue: mockPolicyListService }, - { provide: DialogService, useValue: mockDialogService }, - { provide: PolicyService, useValue: mockPolicyService }, - { provide: ConfigService, useValue: mockConfigService }, - { provide: I18nService, useValue: mockI18nService }, - { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, - { provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] }, - ], - schemas: [NO_ERRORS_SCHEMA], - }) - .overrideComponent(PoliciesComponent, { - remove: { imports: [] }, - add: { template: "
" }, + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [PoliciesComponent], + providers: [ + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService }, + { provide: PolicyListService, useValue: mockPolicyListService }, + { provide: DialogService, useValue: mockDialogService }, + { provide: PolicyService, useValue: mockPolicyService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: I18nService, useValue: mockI18nService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: POLICY_EDIT_REGISTER, useValue: [mockPolicy] }, + ], + schemas: [NO_ERRORS_SCHEMA], }) - .compileComponents(); + .overrideComponent(PoliciesComponent, { + remove: { imports: [] }, + add: { template: "
" }, + }) + .compileComponents(); - const newFixture = TestBed.createComponent(PoliciesComponent); - newFixture.detectChanges(); + fixture = TestBed.createComponent(PoliciesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); - expect(dialogOpenSpy).toHaveBeenCalled(); - const callArgs = dialogOpenSpy.mock.calls[0][1]; - expect(callArgs.data?.policy.type).toBe(mockPolicy.type); - expect(callArgs.data?.organizationId).toBe(mockOrgId); - - newFixture.destroy(); + it("should open policy dialog when policyId is in query params", () => { + expect(dialogOpenSpy).toHaveBeenCalled(); + const callArgs = dialogOpenSpy.mock.calls[0][1]; + expect(callArgs.data?.policy.type).toBe(mockPolicy.type); + expect(callArgs.data?.organizationId).toBe(mockOrgId); + }); }); it("should not open dialog when policyId is not in query params", async () => { diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index 70daf55f662..1f9a8deaa85 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, DestroyRef } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { combineLatest, Observable, of, switchMap, first, map } from "rxjs"; +import { combineLatest, Observable, of, switchMap, first, map, shareReplay } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; @@ -70,6 +70,7 @@ export class PoliciesComponent { switchMap(() => this.organizationId$), switchMap((organizationId) => this.policyApiService.getPolicies(organizationId)), map((response) => (response.data != null && response.data.length > 0 ? response.data : [])), + shareReplay({ bufferSize: 1, refCount: true }), ); protected policiesEnabledMap$: Observable> = this.orgPolicies$.pipe( 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 ec857478a21..ceecf8f2ecc 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,9 +1,8 @@ import { ChangeDetectionStrategy, Component } from "@angular/core"; -import { map, Observable } from "rxjs"; +import { of, Observable } from "rxjs"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SharedModule } from "../../../../shared"; @@ -16,9 +15,8 @@ export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition { component = OrganizationDataOwnershipPolicyComponent; display$(organization: Organization, configService: ConfigService): Observable { - return configService - .getFeatureFlag$(FeatureFlag.CreateDefaultLocation) - .pipe(map((enabled) => !enabled)); + // 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); } } 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 ed26dd37801..59670457d88 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,12 +1,9 @@ import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core"; -import { lastValueFrom, Observable } from "rxjs"; +import { lastValueFrom } 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"; @@ -28,10 +25,6 @@ export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefiniti type = PolicyType.OrganizationDataOwnership; component = vNextOrganizationDataOwnershipPolicyComponent; showDescription = false; - - override display$(organization: Organization, configService: ConfigService): Observable { - return configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation); - } } @Component({ 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 98b6d1c6bee..c633ff5f421 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 @@ -14,8 +14,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { @@ -75,7 +73,6 @@ export class PolicyEditDialogComponent implements AfterViewInit { private formBuilder: FormBuilder, protected dialogRef: DialogRef, protected toastService: ToastService, - private configService: ConfigService, private keyService: KeyService, ) {} @@ -132,10 +129,7 @@ export class PolicyEditDialogComponent implements AfterViewInit { } try { - if ( - this.policyComponent instanceof vNextOrganizationDataOwnershipPolicyComponent && - (await this.isVNextEnabled()) - ) { + if (this.policyComponent instanceof vNextOrganizationDataOwnershipPolicyComponent) { await this.handleVNextSubmission(this.policyComponent); } else { await this.handleStandardSubmission(); @@ -154,14 +148,6 @@ export class PolicyEditDialogComponent implements AfterViewInit { } }; - private async isVNextEnabled(): Promise { - const isVNextFeatureEnabled = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation), - ); - - return isVNextFeatureEnabled; - } - private async handleStandardSubmission(): Promise { if (!this.policyComponent) { throw new Error("PolicyComponent not initialized."); diff --git a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts index b18e3a7f5c3..3ba2f634785 100644 --- a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.spec.ts @@ -5,8 +5,6 @@ import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-pro import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync/sync.service"; @@ -26,7 +24,6 @@ import { describe("UnifiedUpgradePromptService", () => { let sut: UnifiedUpgradePromptService; const mockAccountService = mock(); - const mockConfigService = mock(); const mockBillingService = mock(); const mockVaultProfileService = mock(); const mockSyncService = mock(); @@ -59,7 +56,6 @@ describe("UnifiedUpgradePromptService", () => { function setupTestService() { sut = new UnifiedUpgradePromptService( mockAccountService, - mockConfigService, mockBillingService, mockVaultProfileService, mockSyncService, @@ -80,7 +76,6 @@ describe("UnifiedUpgradePromptService", () => { beforeEach(() => { mockAccountService.activeAccount$ = accountSubject.asObservable(); mockPlatformUtilsService.isSelfHost.mockReturnValue(false); - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockStateProvider.getUserState$.mockReturnValue(of(false)); setupTestService(); @@ -96,7 +91,6 @@ describe("UnifiedUpgradePromptService", () => { mockAccountService.activeAccount$ = accountSubject.asObservable(); mockDialogOpen.mockReset(); mockReset(mockDialogService); - mockReset(mockConfigService); mockReset(mockBillingService); mockReset(mockVaultProfileService); mockReset(mockSyncService); @@ -112,11 +106,10 @@ describe("UnifiedUpgradePromptService", () => { mockStateProvider.getUserState$.mockReturnValue(of(false)); mockStateProvider.setUserState.mockResolvedValue(undefined); }); - it("should subscribe to account and feature flag observables when checking display conditions", async () => { + it("should subscribe to account observables when checking display conditions", async () => { // Arrange mockPlatformUtilsService.isSelfHost.mockReturnValue(false); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); - mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); setupTestService(); @@ -125,34 +118,12 @@ describe("UnifiedUpgradePromptService", () => { await sut.displayUpgradePromptConditionally(); // Assert - expect(mockConfigService.getFeatureFlag$).toHaveBeenCalledWith( - FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog, - ); expect(mockAccountService.activeAccount$).toBeDefined(); }); - it("should not show dialog when feature flag is disabled", async () => { - // Arrange - mockPlatformUtilsService.isSelfHost.mockReturnValue(false); - mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); - mockConfigService.getFeatureFlag$.mockReturnValue(of(false)); - mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); - const recentDate = new Date(); - recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old - mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate); - - setupTestService(); - // Act - const result = await sut.displayUpgradePromptConditionally(); - - // Assert - expect(result).toBeNull(); - expect(mockDialogOpen).not.toHaveBeenCalled(); - }); it("should not show dialog when user has premium", async () => { // Arrange mockPlatformUtilsService.isSelfHost.mockReturnValue(false); - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(true)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); setupTestService(); @@ -167,7 +138,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not show dialog when user has any organization membership", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([{ id: "org1" } as any])); mockPlatformUtilsService.isSelfHost.mockReturnValue(false); @@ -183,7 +153,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not show dialog when profile is older than 5 minutes", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); const oldDate = new Date(); @@ -202,7 +171,6 @@ describe("UnifiedUpgradePromptService", () => { it("should show dialog when all conditions are met", async () => { //Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); const recentDate = new Date(); @@ -224,7 +192,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not show dialog when account is null/undefined", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); accountSubject.next(null); // Set account to null setupTestService(); @@ -238,7 +205,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not show dialog when profile creation date is unavailable", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); mockVaultProfileService.getProfileCreationDate.mockResolvedValue(null); @@ -256,7 +222,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not show dialog when running in self-hosted environment", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); const recentDate = new Date(); @@ -275,7 +240,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not show dialog when user has previously dismissed the modal", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); const recentDate = new Date(); @@ -295,7 +259,6 @@ describe("UnifiedUpgradePromptService", () => { it("should save dismissal state when user closes the dialog", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); const recentDate = new Date(); @@ -320,7 +283,6 @@ describe("UnifiedUpgradePromptService", () => { it("should not save dismissal state when user upgrades to premium", async () => { // Arrange - mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); mockOrganizationService.memberOrganizations$.mockReturnValue(of([])); const recentDate = new Date(); diff --git a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts index 3ea8f19341d..f5a32483a4d 100644 --- a/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/services/unified-upgrade-prompt.service.ts @@ -6,8 +6,6 @@ import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-pro import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync/sync.service"; @@ -38,7 +36,6 @@ export class UnifiedUpgradePromptService { private unifiedUpgradeDialogRef: DialogRef | null = null; constructor( private accountService: AccountService, - private configService: ConfigService, private billingAccountProfileStateService: BillingAccountProfileStateService, private vaultProfileService: VaultProfileService, private syncService: SyncService, @@ -70,26 +67,13 @@ export class UnifiedUpgradePromptService { isProfileLessThanFiveMinutesOld$, hasOrganizations$, this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), - this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog), hasDismissedModal$, ]).pipe( - map( - ([ - isProfileLessThanFiveMinutesOld, - hasOrganizations, - hasPremium, - isFlagEnabled, - hasDismissed, - ]) => { - return ( - isProfileLessThanFiveMinutesOld && - !hasOrganizations && - !hasPremium && - isFlagEnabled && - !hasDismissed - ); - }, - ), + map(([isProfileLessThanFiveMinutesOld, hasOrganizations, hasPremium, hasDismissed]) => { + return ( + isProfileLessThanFiveMinutesOld && !hasOrganizations && !hasPremium && !hasDismissed + ); + }), ); }), take(1), diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html index 45a68136a00..02dd69be6aa 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -50,11 +50,7 @@
- + @if (isFamiliesPlan) {

{{ "paymentChargedWithTrial" | i18n }} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index a824e850db6..34362b4be3e 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -9,7 +9,7 @@ import { signal, viewChild, } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { debounceTime, @@ -22,6 +22,7 @@ import { combineLatest, map, shareReplay, + defer, } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; @@ -35,7 +36,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; -import { CartSummaryComponent } from "@bitwarden/pricing"; +import { Cart, CartSummaryComponent } from "@bitwarden/pricing"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { @@ -118,23 +119,48 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { protected readonly selectedPlan = signal(null); protected readonly loading = signal(true); protected readonly upgradeToMessage = signal(""); - // Cart Summary data - protected readonly passwordManager = computed(() => { - if (!this.selectedPlan()) { - return { name: "", cost: 0, quantity: 0, cadence: "year" as const }; - } - - return { - name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", - cost: this.selectedPlan()!.details.passwordManager.annualPrice, - quantity: 1, - cadence: "year" as const, - }; - }); protected hasEnoughAccountCredit$!: Observable; private pricingTiers$!: Observable; - protected estimatedTax$!: Observable; + + // Use defer to lazily create the observable when subscribed to + protected estimatedTax$ = defer(() => + this.formGroup.controls.billingAddress.valueChanges.pipe( + startWith(this.formGroup.controls.billingAddress.value), + debounceTime(1000), + switchMap(() => this.refreshSalesTax$()), + ), + ); + + // Convert estimatedTax$ to signal for use in computed cart + protected readonly estimatedTax = toSignal(this.estimatedTax$, { + initialValue: this.INITIAL_TAX_VALUE, + }); + + // Cart Summary data + protected readonly cart = computed(() => { + if (!this.selectedPlan()) { + return { + passwordManager: { + seats: { name: "", cost: 0, quantity: 0 }, + }, + cadence: "annually", + estimatedTax: 0, + }; + } + + return { + passwordManager: { + seats: { + name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + cost: this.selectedPlan()!.details.passwordManager.annualPrice ?? 0, + quantity: 1, + }, + }, + cadence: "annually", + estimatedTax: this.estimatedTax() ?? 0, + }; + }); constructor( private i18nService: I18nService, @@ -186,13 +212,6 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { } }); - this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe( - startWith(this.formGroup.controls.billingAddress.value), - debounceTime(1000), - // Only proceed when form has required values - switchMap(() => this.refreshSalesTax$()), - ); - this.loading.set(false); } diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index 2d653ff200b..307f170f116 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -65,7 +65,7 @@ }}

diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 8d99b807540..2fc39218cf8 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -17,7 +17,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { DiscountInfo } from "@bitwarden/pricing"; +import { Discount, DiscountTypes, Maybe } from "@bitwarden/pricing"; import { AdjustStorageDialogComponent, @@ -251,15 +251,13 @@ export class UserSubscriptionComponent implements OnInit { } } - getDiscountInfo(discount: BillingCustomerDiscount | null): DiscountInfo | null { + getDiscount(discount: BillingCustomerDiscount | null): Maybe { if (!discount) { return null; } - return { - active: discount.active, - percentOff: discount.percentOff, - amountOff: discount.amountOff, - }; + return discount.amountOff + ? { type: DiscountTypes.AmountOff, active: discount.active, value: discount.amountOff } + : { type: DiscountTypes.PercentOff, active: discount.active, value: discount.percentOff }; } get isSubscriptionActive(): boolean { diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 860f80eb346..4858deabec6 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -40,21 +40,27 @@ {{ i.amount | currency: "$" }} - + {{ "freeForOneYear" | i18n }}
- {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }} + {{ i.quantity * i.amount | currency: "$" }} / + {{ i.interval | i18n }} {{ - calculateTotalAppliedDiscount(i.quantity * i.amount) | currency: "$" - }} - / {{ "year" | i18n }}{{ i.quantity * i.originalAmount | currency: "$" }} / + {{ "year" | i18n }}
diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index de5d71cce5e..323a190fe1c 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -19,11 +19,9 @@ import { 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 { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { BillingSubscriptionItemResponse } from "@bitwarden/common/billing/models/response/subscription.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -82,9 +80,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy private organizationApiService: OrganizationApiServiceAbstraction, private route: ActivatedRoute, private dialogService: DialogService, - private configService: ConfigService, private toastService: ToastService, - private billingApiService: BillingApiServiceAbstraction, private organizationUserApiService: OrganizationUserApiService, ) {} @@ -218,6 +214,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy get subscriptionLineItems() { return this.lineItems.map((lineItem: BillingSubscriptionItemResponse) => ({ name: lineItem.name, + originalAmount: lineItem.amount, amount: this.discountPrice(lineItem.amount, lineItem.productId), quantity: lineItem.quantity, interval: lineItem.interval, @@ -406,12 +403,16 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy const isSmStandalone = this.sub?.customerDiscount?.id === "sm-standalone"; const appliesToProduct = this.sub?.subscription?.items?.some((item) => - this.sub?.customerDiscount?.appliesTo?.includes(item.productId), + this.discountAppliesToProduct(item.productId), ) ?? false; return isSmStandalone && appliesToProduct; } + discountAppliesToProduct(productId: string): boolean { + return this.sub?.customerDiscount?.appliesTo?.includes(productId) ?? false; + } + closeChangePlan() { this.showChangePlan = false; } @@ -438,10 +439,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy await this.load(); } - calculateTotalAppliedDiscount(total: number) { - return total / (1 - this.customerDiscount?.percentOff / 100); - } - adjustStorage = (add: boolean) => { return async () => { const dialogRef = AdjustStorageDialogComponent.open(this.dialogService, { diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index bd557dc5947..661d14502fe 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -9,8 +9,6 @@ import { DefaultCollectionAdminService, OrganizationUserApiService, CollectionService, - AutomaticUserConfirmationService, - DefaultAutomaticUserConfirmationService, OrganizationUserService, DefaultOrganizationUserService, } from "@bitwarden/admin-console/common"; @@ -46,6 +44,10 @@ import { InternalUserDecryptionOptionsServiceAbstraction, LoginEmailService, } from "@bitwarden/auth/common"; +import { + AutomaticUserConfirmationService, + DefaultAutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { @@ -59,9 +61,11 @@ import { } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service"; import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; @@ -374,6 +378,7 @@ const safeProviders: SafeProvider[] = [ StateProvider, InternalOrganizationServiceAbstraction, OrganizationUserApiService, + PolicyService, ], }), safeProvider({ @@ -483,6 +488,11 @@ const safeProviders: SafeProvider[] = [ useClass: SessionTimeoutSettingsComponentService, deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyService], }), + safeProvider({ + provide: AuthRequestAnsweringService, + useClass: NoopAuthRequestAnsweringService, + deps: [], + }), ]; @NgModule({ diff --git a/apps/web/src/app/layouts/navbar.component.html b/apps/web/src/app/layouts/navbar.component.html deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts index f7f319f2fab..b46629bc6a8 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts @@ -54,6 +54,7 @@ describe("ProductSwitcherService", () => { platformUtilsService = mock(); billingAccountProfileStateService = mock(); configService = mock(); + configService.getFeatureFlag$.mockReturnValue(of(false)); router.url = "/"; router.events = of({}); diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts index 6cfecd59403..31fcf9ffe6d 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts @@ -19,7 +19,11 @@ import { } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { PolicyType, ProviderType } from "@bitwarden/common/admin-console/enums"; +import { + OrganizationUserType, + PolicyType, + ProviderType, +} from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -155,14 +159,16 @@ export class ProductSwitcherService { this.userHasSingleOrgPolicy$, this.route.paramMap, this.triggerProductUpdate$, + this.configService.getFeatureFlag$(FeatureFlag.SM1719_RemoveSecretsManagerAds), ]).pipe( map( - ([orgs, providers, userHasSingleOrgPolicy, paramMap]: [ + ([orgs, providers, userHasSingleOrgPolicy, paramMap, , removeSecretsManagerAdsFlag]: [ Organization[], Provider[], boolean, ParamMap, void, + boolean, ]) => { // Sort orgs by name to match the order within the sidebar orgs.sort((a, b) => a.name.localeCompare(b.name)); @@ -208,6 +214,15 @@ export class ProductSwitcherService { external: false, }; + // Check if SM ads should be disabled for any organization + // SM ads are only disabled if the feature flag is enabled AND + // the user is a regular User (not Admin or Owner) in an organization that has useDisableSMAdsForUsers enabled + const shouldDisableSMAds = + removeSecretsManagerAdsFlag && + orgs.some( + (org) => org.useDisableSMAdsForUsers === true && org.type === OrganizationUserType.User, + ); + const products = { pm: { name: "Password Manager", @@ -267,7 +282,8 @@ export class ProductSwitcherService { if (smOrg) { bento.push(products.sm); - } else { + } else if (!shouldDisableSMAds) { + // Only show SM in "other" section if ads are not disabled other.push(products.sm); } diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 3af514466b7..90207f59ad4 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -4,12 +4,12 @@ import { CommonModule } from "@angular/common"; import { Component, OnInit, Signal } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { RouterModule } from "@angular/router"; -import { combineLatest, map, Observable, switchMap } from "rxjs"; +import { Observable, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PasswordManagerLogo } from "@bitwarden/assets/svg"; +import { canAccessEmergencyAccess } 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; @@ -58,21 +58,11 @@ export class UserLayoutComponent implements OnInit { ); this.showEmergencyAccess = toSignal( - combineLatest([ - this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm), - this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => - this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId), - ), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + canAccessEmergencyAccess(userId, this.configService, this.policyService), ), - ]).pipe( - map(([enabled, policyAppliesToUser]) => { - if (!enabled || !policyAppliesToUser) { - return true; - } - return false; - }), ), ); diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index f4fd55bd1e6..932d0b8119b 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { Route, RouterModule, Routes } from "@angular/router"; +import { organizationPolicyGuard } from "@bitwarden/angular/admin-console/guards"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; import { AuthRoute } from "@bitwarden/angular/auth/constants"; import { @@ -56,7 +57,6 @@ import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/gua import { flagEnabled, Flags } from "../utils/flags"; -import { organizationPolicyGuard } from "./admin-console/organizations/guards/org-policy.guard"; import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/manage/verify-recover-delete-org.component"; import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component"; import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component"; diff --git a/apps/web/src/app/tools/event-export/event-export.service.ts b/apps/web/src/app/tools/event-export/event-export.service.ts index f39b786b6d1..d888af51edf 100644 --- a/apps/web/src/app/tools/event-export/event-export.service.ts +++ b/apps/web/src/app/tools/event-export/event-export.service.ts @@ -4,6 +4,7 @@ import { Injectable } from "@angular/core"; import * as papa from "papaparse"; import { EventView } from "@bitwarden/common/models/view/event.view"; +import { ExportHelper } from "@bitwarden/vault-export-core"; import { EventExport } from "./event.export"; @@ -16,25 +17,6 @@ export class EventExportService { } getFileName(prefix: string = null, extension = "csv"): string { - const now = new Date(); - const dateString = - now.getFullYear() + - "" + - this.padNumber(now.getMonth() + 1, 2) + - "" + - this.padNumber(now.getDate(), 2) + - this.padNumber(now.getHours(), 2) + - "" + - this.padNumber(now.getMinutes(), 2) + - this.padNumber(now.getSeconds(), 2); - - return "bitwarden" + (prefix ? "_" + prefix : "") + "_export_" + dateString + "." + extension; - } - - private padNumber(num: number, width: number, padCharacter = "0"): string { - const numString = num.toString(); - return numString.length >= width - ? numString - : new Array(width - numString.length + 1).join(padCharacter) + numString; + return ExportHelper.getFileName(prefix ?? "", extension); } } diff --git a/apps/web/src/app/tools/send/send-access/access.component.html b/apps/web/src/app/tools/send/send-access/access.component.html index aec6e2a10b9..b86933410b8 100644 --- a/apps/web/src/app/tools/send/send-access/access.component.html +++ b/apps/web/src/app/tools/send/send-access/access.component.html @@ -1,52 +1,14 @@ -
- - {{ "viewSendHiddenEmailWarning" | i18n }} - {{ - "learnMore" | i18n - }}. - - - -
-

{{ "sendAccessUnavailable" | i18n }}

-
-
-

{{ "unexpectedErrorSend" | i18n }}

-
-
-

- {{ send.name }} -

-
- - - - - - - - -

- Expires: {{ expirationDate | date: "medium" }} -

-
-
- -
- - {{ "loading" | i18n }} -
-
-
+@switch (viewState) { + @case ("auth") { + + } + @case ("view") { + + } +} diff --git a/apps/web/src/app/tools/send/send-access/access.component.ts b/apps/web/src/app/tools/send/send-access/access.component.ts index 273f1c8c979..4ea469a0b1c 100644 --- a/apps/web/src/app/tools/send/send-access/access.component.ts +++ b/apps/web/src/app/tools/send/send-access/access.component.ts @@ -1,161 +1,60 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, OnInit } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; -import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; -import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; -import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; -import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components"; -import { KeyService } from "@bitwarden/key-management"; import { SharedModule } from "../../../shared"; -import { SendAccessFileComponent } from "./send-access-file.component"; -import { SendAccessPasswordComponent } from "./send-access-password.component"; -import { SendAccessTextComponent } from "./send-access-text.component"; +import { SendAuthComponent } from "./send-auth.component"; +import { SendViewComponent } from "./send-view.component"; + +const SendViewState = Object.freeze({ + View: "view", + Auth: "auth", +} as const); +type SendViewState = (typeof SendViewState)[keyof typeof SendViewState]; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send-access", templateUrl: "access.component.html", - imports: [ - SendAccessFileComponent, - SendAccessTextComponent, - SendAccessPasswordComponent, - SharedModule, - ], + imports: [SendAuthComponent, SendViewComponent, SharedModule], }) export class AccessComponent implements OnInit { - protected send: SendAccessView; - protected sendType = SendType; - protected loading = true; - protected passwordRequired = false; - protected formPromise: Promise; - protected password: string; - protected unavailable = false; - protected error = false; - protected hideEmail = false; - protected decKey: SymmetricCryptoKey; - protected accessRequest: SendAccessRequest; + viewState: SendViewState = SendViewState.View; + id: string; + key: string; - protected formGroup = this.formBuilder.group({}); + sendAccessResponse: SendAccessResponse | null = null; + sendAccessRequest: SendAccessRequest = new SendAccessRequest(); - private id: string; - private key: string; - - constructor( - private cryptoFunctionService: CryptoFunctionService, - private route: ActivatedRoute, - private keyService: KeyService, - private sendApiService: SendApiService, - private toastService: ToastService, - private i18nService: I18nService, - private layoutWrapperDataService: AnonLayoutWrapperDataService, - protected formBuilder: FormBuilder, - ) {} - - protected get expirationDate() { - if (this.send == null || this.send.expirationDate == null) { - return null; - } - return this.send.expirationDate; - } - - protected get creatorIdentifier() { - if (this.send == null || this.send.creatorIdentifier == null) { - return null; - } - return this.send.creatorIdentifier; - } + constructor(private route: ActivatedRoute) {} async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.params.subscribe(async (params) => { this.id = params.sendId; this.key = params.key; - if (this.key == null || this.id == null) { - return; + + if (this.id && this.key) { + this.viewState = SendViewState.View; + this.sendAccessResponse = null; + this.sendAccessRequest = new SendAccessRequest(); } - await this.load(); }); } - protected load = async () => { - this.unavailable = false; - this.error = false; - this.hideEmail = false; - try { - const keyArray = Utils.fromUrlB64ToArray(this.key); - this.accessRequest = new SendAccessRequest(); - if (this.password != null) { - const passwordHash = await this.cryptoFunctionService.pbkdf2( - this.password, - keyArray, - "sha256", - SEND_KDF_ITERATIONS, - ); - this.accessRequest.password = Utils.fromBufferToB64(passwordHash); - } - let sendResponse: SendAccessResponse = null; - if (this.loading) { - sendResponse = await this.sendApiService.postSendAccess(this.id, this.accessRequest); - } else { - this.formPromise = this.sendApiService.postSendAccess(this.id, this.accessRequest); - sendResponse = await this.formPromise; - } - this.passwordRequired = false; - const sendAccess = new SendAccess(sendResponse); - this.decKey = await this.keyService.makeSendKey(keyArray); - this.send = await sendAccess.decrypt(this.decKey); - } catch (e) { - if (e instanceof ErrorResponse) { - if (e.statusCode === 401) { - this.passwordRequired = true; - } else if (e.statusCode === 404) { - this.unavailable = true; - } else if (e.statusCode === 400) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: e.message, - }); - } else { - this.error = true; - } - } else { - this.error = true; - } - } - this.loading = false; - this.hideEmail = - this.creatorIdentifier == null && - !this.passwordRequired && - !this.loading && - !this.unavailable; + onAuthRequired() { + this.viewState = SendViewState.Auth; + } - if (this.creatorIdentifier != null) { - this.layoutWrapperDataService.setAnonLayoutWrapperData({ - pageSubtitle: { - key: "sendAccessCreatorIdentifier", - placeholders: [this.creatorIdentifier], - }, - }); - } - }; - - protected setPassword(password: string) { - this.password = password; + onAccessGranted(event: { response: SendAccessResponse; request: SendAccessRequest }) { + this.sendAccessResponse = event.response; + this.sendAccessRequest = event.request; + this.viewState = SendViewState.View; } } diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.html b/apps/web/src/app/tools/send/send-access/send-auth.component.html new file mode 100644 index 00000000000..21a6de50ba8 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.html @@ -0,0 +1,14 @@ +
+
+

{{ "sendAccessUnavailable" | i18n }}

+
+
+

{{ "unexpectedErrorSend" | i18n }}

+
+ + +
diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.ts b/apps/web/src/app/tools/send/send-access/send-auth.component.ts new file mode 100644 index 00000000000..b360044a8b6 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.ts @@ -0,0 +1,86 @@ +import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; + +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; +import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; +import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { ToastService } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; + +import { SendAccessPasswordComponent } from "./send-access-password.component"; + +@Component({ + selector: "app-send-auth", + templateUrl: "send-auth.component.html", + imports: [SendAccessPasswordComponent, SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendAuthComponent { + readonly id = input.required(); + readonly key = input.required(); + + accessGranted = output<{ + response: SendAccessResponse; + request: SendAccessRequest; + }>(); + + loading = false; + error = false; + unavailable = false; + password?: string; + + private accessRequest!: SendAccessRequest; + + constructor( + private cryptoFunctionService: CryptoFunctionService, + private sendApiService: SendApiService, + private toastService: ToastService, + private i18nService: I18nService, + ) {} + + async onSubmit(password: string) { + this.password = password; + this.loading = true; + this.error = false; + this.unavailable = false; + + try { + const keyArray = Utils.fromUrlB64ToArray(this.key()); + this.accessRequest = new SendAccessRequest(); + + const passwordHash = await this.cryptoFunctionService.pbkdf2( + this.password, + keyArray, + "sha256", + SEND_KDF_ITERATIONS, + ); + this.accessRequest.password = Utils.fromBufferToB64(passwordHash); + + const sendResponse = await this.sendApiService.postSendAccess(this.id(), this.accessRequest); + this.accessGranted.emit({ response: sendResponse, request: this.accessRequest }); + } catch (e) { + if (e instanceof ErrorResponse) { + if (e.statusCode === 404) { + this.unavailable = true; + } else if (e.statusCode === 400) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: e.message, + }); + } else { + this.error = true; + } + } else { + this.error = true; + } + } finally { + this.loading = false; + } + } +} diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.html b/apps/web/src/app/tools/send/send-access/send-view.component.html new file mode 100644 index 00000000000..dd0b770b261 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-view.component.html @@ -0,0 +1,47 @@ + + {{ "viewSendHiddenEmailWarning" | i18n }} + {{ + "learnMore" | i18n + }}. + + + +
+

{{ "sendAccessUnavailable" | i18n }}

+
+
+

{{ "unexpectedErrorSend" | i18n }}

+
+
+

+ {{ send.name }} +

+
+ + + + + + + + +

+ Expires: {{ expirationDate | date: "medium" }} +

+
+
+ +
+ + {{ "loading" | i18n }} +
+
diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.ts b/apps/web/src/app/tools/send/send-access/send-view.component.ts new file mode 100644 index 00000000000..0397575f021 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-view.component.ts @@ -0,0 +1,131 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + input, + OnInit, + output, +} from "@angular/core"; + +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; +import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; +import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; +import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +import { SharedModule } from "../../../shared"; + +import { SendAccessFileComponent } from "./send-access-file.component"; +import { SendAccessTextComponent } from "./send-access-text.component"; + +@Component({ + selector: "app-send-view", + templateUrl: "send-view.component.html", + imports: [SendAccessFileComponent, SendAccessTextComponent, SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendViewComponent implements OnInit { + readonly id = input.required(); + readonly key = input.required(); + readonly sendResponse = input(null); + readonly accessRequest = input(new SendAccessRequest()); + + authRequired = output(); + + send: SendAccessView | null = null; + sendType = SendType; + loading = true; + unavailable = false; + error = false; + hideEmail = false; + decKey!: SymmetricCryptoKey; + + constructor( + private keyService: KeyService, + private sendApiService: SendApiService, + private toastService: ToastService, + private i18nService: I18nService, + private layoutWrapperDataService: AnonLayoutWrapperDataService, + private cdRef: ChangeDetectorRef, + ) {} + + get expirationDate() { + if (this.send == null || this.send.expirationDate == null) { + return null; + } + return this.send.expirationDate; + } + + get creatorIdentifier() { + if (this.send == null || this.send.creatorIdentifier == null) { + return null; + } + return this.send.creatorIdentifier; + } + + async ngOnInit() { + await this.load(); + } + + private async load() { + this.unavailable = false; + this.error = false; + this.hideEmail = false; + this.loading = true; + + let response = this.sendResponse(); + + try { + if (!response) { + response = await this.sendApiService.postSendAccess(this.id(), this.accessRequest()); + } + + const keyArray = Utils.fromUrlB64ToArray(this.key()); + const sendAccess = new SendAccess(response); + this.decKey = await this.keyService.makeSendKey(keyArray); + this.send = await sendAccess.decrypt(this.decKey); + } catch (e) { + if (e instanceof ErrorResponse) { + if (e.statusCode === 401) { + this.authRequired.emit(); + } else if (e.statusCode === 404) { + this.unavailable = true; + } else if (e.statusCode === 400) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: e.message, + }); + } else { + this.error = true; + } + } else { + this.error = true; + } + } + + this.loading = false; + this.hideEmail = + this.creatorIdentifier == null && !this.loading && !this.unavailable && !response; + + this.hideEmail = this.send != null && this.creatorIdentifier == null; + + if (this.creatorIdentifier != null) { + this.layoutWrapperDataService.setAnonLayoutWrapperData({ + pageSubtitle: { + key: "sendAccessCreatorIdentifier", + placeholders: [this.creatorIdentifier], + }, + }); + } + + this.cdRef.markForCheck(); + } +} diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html index 1976321b4ee..8cfd394b854 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html @@ -64,11 +64,11 @@

{{ "gettingStartedWithBitwardenPart1" | i18n }} - + {{ "gettingStartedWithBitwardenPart2" | i18n }} {{ "and" | i18n }} - + {{ "gettingStartedWithBitwardenPart3" | i18n }}

diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index 3aa2f4b3bc1..16256ab875a 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -3,7 +3,7 @@ {{ title }} @if (cipherIsArchived) { - {{ "archiveNoun" | i18n }} + {{ "archived" | i18n }} }
diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index a723f1e942b..e437537b1cc 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -144,8 +144,9 @@ export class VaultCipherRowComponent implements OnInit } } + // Archive button will not show in Admin Console protected get showArchiveButton() { - if (!this.archiveEnabled()) { + if (!this.archiveEnabled() || this.viewingOrgVault) { return false; } diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts index 2ba9dd6fad4..20148018c39 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from "@angular/core/testing"; -import { BehaviorSubject, firstValueFrom, take, timeout } from "rxjs"; +import { BehaviorSubject } from "rxjs"; import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -17,11 +17,7 @@ import { import { UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { - PREMIUM_BANNER_REPROMPT_KEY, - VaultBannersService, - VisibleVaultBanner, -} from "./vault-banners.service"; +import { VaultBannersService, VisibleVaultBanner } from "./vault-banners.service"; describe("VaultBannersService", () => { let service: VaultBannersService; @@ -79,101 +75,6 @@ describe("VaultBannersService", () => { jest.useRealTimers(); }); - describe("Premium", () => { - it("waits until sync is completed before showing premium banner", async () => { - hasPremiumFromAnySource$.next(false); - isSelfHost.mockReturnValue(false); - lastSync$.next(null); - - service = TestBed.inject(VaultBannersService); - - const premiumBanner$ = service.shouldShowPremiumBanner$(userId); - - // Should not emit when sync is null - await expect(firstValueFrom(premiumBanner$.pipe(take(1), timeout(100)))).rejects.toThrow(); - - // Should emit when sync is completed - lastSync$.next(new Date("2024-05-14")); - expect(await firstValueFrom(premiumBanner$)).toBe(true); - }); - - it("does not show a premium banner for self-hosted users", async () => { - hasPremiumFromAnySource$.next(false); - isSelfHost.mockReturnValue(true); - - service = TestBed.inject(VaultBannersService); - - expect(await firstValueFrom(service.shouldShowPremiumBanner$(userId))).toBe(false); - }); - - it("does not show a premium banner when they have access to premium", async () => { - hasPremiumFromAnySource$.next(true); - isSelfHost.mockReturnValue(false); - - service = TestBed.inject(VaultBannersService); - - expect(await firstValueFrom(service.shouldShowPremiumBanner$(userId))).toBe(false); - }); - - describe("dismissing", () => { - beforeEach(async () => { - jest.useFakeTimers(); - const date = new Date("2023-06-08"); - date.setHours(0, 0, 0, 0); - jest.setSystemTime(date.getTime()); - - service = TestBed.inject(VaultBannersService); - await service.dismissBanner(userId, VisibleVaultBanner.Premium); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it("updates state on first dismiss", async () => { - const state = await firstValueFrom( - fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$, - ); - - const oneWeekLater = new Date("2023-06-15"); - oneWeekLater.setHours(0, 0, 0, 0); - - expect(state).toEqual({ - numberOfDismissals: 1, - nextPromptDate: oneWeekLater.getTime(), - }); - }); - - it("updates state on second dismiss", async () => { - const state = await firstValueFrom( - fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$, - ); - - const oneMonthLater = new Date("2023-07-08"); - oneMonthLater.setHours(0, 0, 0, 0); - - expect(state).toEqual({ - numberOfDismissals: 2, - nextPromptDate: oneMonthLater.getTime(), - }); - }); - - it("updates state on third dismiss", async () => { - const state = await firstValueFrom( - fakeStateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY).state$, - ); - - const oneYearLater = new Date("2024-06-08"); - oneYearLater.setHours(0, 0, 0, 0); - - expect(state).toEqual({ - numberOfDismissals: 3, - nextPromptDate: oneYearLater.getTime(), - }); - }); - }); - }); - describe("OutdatedBrowser", () => { beforeEach(async () => { // Hardcode `MSIE` in userAgent string diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts index 1c53274d9d7..6371f78c0f5 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts @@ -1,5 +1,5 @@ import { Injectable } from "@angular/core"; -import { Observable, combineLatest, firstValueFrom, map, filter, mergeMap, take } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -7,7 +7,6 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateProvider, - PREMIUM_BANNER_DISK_LOCAL, BANNERS_DISMISSED_DISK, UserKeyDefinition, SingleUserState, @@ -18,30 +17,14 @@ import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; export const VisibleVaultBanner = { OutdatedBrowser: "outdated-browser", - Premium: "premium", VerifyEmail: "verify-email", PendingAuthRequest: "pending-auth-request", } as const; export type VisibleVaultBanner = UnionOfValues; -type PremiumBannerReprompt = { - numberOfDismissals: number; - /** Timestamp representing when to show the prompt next */ - nextPromptDate: number; -}; - /** Banners that will be re-shown on a new session */ -type SessionBanners = Omit; - -export const PREMIUM_BANNER_REPROMPT_KEY = new UserKeyDefinition( - PREMIUM_BANNER_DISK_LOCAL, - "bannerReprompt", - { - deserializer: (bannerReprompt) => bannerReprompt, - clearOn: [], // Do not clear user tutorials - }, -); +type SessionBanners = VisibleVaultBanner; export const BANNERS_DISMISSED_DISK_KEY = new UserKeyDefinition( BANNERS_DISMISSED_DISK, @@ -76,33 +59,6 @@ export class VaultBannersService { return pendingAuthRequests.length > 0 && !alreadyDismissed; } - shouldShowPremiumBanner$(userId: UserId): Observable { - const premiumBannerState = this.premiumBannerState(userId); - const premiumSources$ = combineLatest([ - this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId), - premiumBannerState.state$, - ]); - - return this.syncService.lastSync$(userId).pipe( - filter((lastSync) => lastSync !== null), - take(1), // Wait until the first sync is complete before considering the premium status - mergeMap(() => premiumSources$), - map(([canAccessPremium, dismissedState]) => { - const shouldShowPremiumBanner = - !canAccessPremium && !this.platformUtilsService.isSelfHost(); - - // Check if nextPromptDate is in the past passed - if (shouldShowPremiumBanner && dismissedState?.nextPromptDate) { - const nextPromptDate = new Date(dismissedState.nextPromptDate); - const now = new Date(); - return now >= nextPromptDate; - } - - return shouldShowPremiumBanner; - }), - ); - } - /** Returns true when the update browser banner should be shown */ async shouldShowUpdateBrowserBanner(userId: UserId): Promise { const outdatedBrowser = window.navigator.userAgent.indexOf("MSIE") !== -1; @@ -128,23 +84,11 @@ export class VaultBannersService { /** Dismiss the given banner and perform any respective side effects */ async dismissBanner(userId: UserId, banner: SessionBanners): Promise { - if (banner === VisibleVaultBanner.Premium) { - await this.dismissPremiumBanner(userId); - } else { - await this.sessionBannerState(userId).update((current) => { - const bannersDismissed = current ?? []; + await this.sessionBannerState(userId).update((current) => { + const bannersDismissed = current ?? []; - return [...bannersDismissed, banner]; - }); - } - } - - /** - * - * @returns a SingleUserState for the premium banner reprompt state - */ - private premiumBannerState(userId: UserId): SingleUserState { - return this.stateProvider.getUser(userId, PREMIUM_BANNER_REPROMPT_KEY); + return [...bannersDismissed, banner]; + }); } /** @@ -161,42 +105,4 @@ export class VaultBannersService { // use nullish coalescing to default to an empty array return (await firstValueFrom(this.sessionBannerState(userId).state$)) ?? []; } - - /** Increment dismissal state of the premium banner */ - private async dismissPremiumBanner(userId: UserId): Promise { - await this.premiumBannerState(userId).update((current) => { - const numberOfDismissals = current?.numberOfDismissals ?? 0; - const now = new Date(); - - // Set midnight of the current day - now.setHours(0, 0, 0, 0); - - // First dismissal, re-prompt in 1 week - if (numberOfDismissals === 0) { - now.setDate(now.getDate() + 7); - return { - numberOfDismissals: 1, - nextPromptDate: now.getTime(), - }; - } - - // Second dismissal, re-prompt in 1 month - if (numberOfDismissals === 1) { - now.setMonth(now.getMonth() + 1); - return { - numberOfDismissals: 2, - nextPromptDate: now.getTime(), - }; - } - - // 3+ dismissals, re-prompt each year - // Avoid day/month edge cases and only increment year - const nextYear = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate()); - nextYear.setHours(0, 0, 0, 0); - return { - numberOfDismissals: numberOfDismissals + 1, - nextPromptDate: nextYear.getTime(), - }; - }); - } } diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html index 44b2975ee19..f197853ac18 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html @@ -44,15 +44,3 @@ (onDismiss)="dismissBanner(VisibleVaultBanner.VerifyEmail)" (onVerified)="dismissBanner(VisibleVaultBanner.VerifyEmail)" > - - - {{ "premiumUpgradeUnlockFeatures" | i18n }} - - {{ "goPremium" | i18n }} - - diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts index 7730ab974fb..786f9a76a20 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts @@ -1,5 +1,4 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { By } from "@angular/platform-browser"; import { RouterTestingModule } from "@angular/router/testing"; import { mock } from "jest-mock-extended"; import { BehaviorSubject, Subject } from "rxjs"; @@ -8,15 +7,13 @@ import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; -import { BannerComponent, BannerModule } from "@bitwarden/components"; +import { BannerModule } from "@bitwarden/components"; import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component"; import { SharedModule } from "../../../shared"; @@ -30,11 +27,9 @@ describe("VaultBannersComponent", () => { let messageSubject: Subject<{ command: string }>; const premiumBanner$ = new BehaviorSubject(false); const pendingAuthRequest$ = new BehaviorSubject(false); - const PM24996_ImplementUpgradeFromFreeDialogFlag$ = new BehaviorSubject(false); const mockUserId = Utils.newGuid() as UserId; const bannerService = mock({ - shouldShowPremiumBanner$: jest.fn((userId: UserId) => premiumBanner$), shouldShowUpdateBrowserBanner: jest.fn(), shouldShowVerifyEmailBanner: jest.fn(), shouldShowPendingAuthRequestBanner: jest.fn((userId: UserId) => @@ -88,17 +83,6 @@ describe("VaultBannersComponent", () => { allMessages$: messageSubject.asObservable(), }), }, - { - provide: ConfigService, - useValue: mock({ - getFeatureFlag$: jest.fn((flag: FeatureFlag) => { - if (flag === FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog) { - return PM24996_ImplementUpgradeFromFreeDialogFlag$; - } - return new BehaviorSubject(false); - }), - }), - }, ], }) .overrideProvider(VaultBannersService, { useValue: bannerService }) @@ -112,53 +96,6 @@ describe("VaultBannersComponent", () => { fixture.detectChanges(); }); - describe("premiumBannerVisible$", () => { - beforeEach(() => { - // Reset feature flag to default (false) before each test - PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false); - }); - - it("shows premium banner when shouldShowPremiumBanner is true and feature flag is off", async () => { - premiumBanner$.next(true); - PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false); - - fixture.detectChanges(); - - const banner = fixture.debugElement.query(By.directive(BannerComponent)); - expect(banner.componentInstance.bannerType()).toBe("premium"); - }); - - it("hides premium banner when feature flag is enabled", async () => { - premiumBanner$.next(true); - PM24996_ImplementUpgradeFromFreeDialogFlag$.next(true); - - fixture.detectChanges(); - - const banner = fixture.debugElement.query(By.directive(BannerComponent)); - expect(banner).toBeNull(); - }); - - it("dismisses premium banner when shouldShowPremiumBanner is false", async () => { - premiumBanner$.next(false); - PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false); - - fixture.detectChanges(); - - const banner = fixture.debugElement.query(By.directive(BannerComponent)); - expect(banner).toBeNull(); - }); - - it("hides premium banner when both shouldShowPremiumBanner is false and feature flag is enabled", async () => { - premiumBanner$.next(false); - PM24996_ImplementUpgradeFromFreeDialogFlag$.next(true); - - fixture.detectChanges(); - - const banner = fixture.debugElement.query(By.directive(BannerComponent)); - expect(banner).toBeNull(); - }); - }); - describe("determineVisibleBanner", () => { [ { diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts index 80626d258f8..9ddfdedd61b 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts @@ -1,14 +1,11 @@ import { Component, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { combineLatest, filter, firstValueFrom, map, Observable, switchMap } from "rxjs"; +import { filter, firstValueFrom, map } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessageListener } from "@bitwarden/common/platform/messaging"; -import { UserId } from "@bitwarden/common/types/guid"; import { BannerModule } from "@bitwarden/components"; import { OrganizationFreeTrialWarningComponent } from "@bitwarden/web-vault/app/billing/organizations/warnings/components"; @@ -32,7 +29,6 @@ import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banner }) export class VaultBannersComponent implements OnInit { visibleBanners: VisibleVaultBanner[] = []; - premiumBannerVisible$: Observable; VisibleVaultBanner = VisibleVaultBanner; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @@ -45,23 +41,7 @@ export class VaultBannersComponent implements OnInit { private router: Router, private accountService: AccountService, private messageListener: MessageListener, - private configService: ConfigService, ) { - this.premiumBannerVisible$ = this.activeUserId$.pipe( - filter((userId): userId is UserId => userId != null), - switchMap((userId) => - combineLatest([ - this.vaultBannerService.shouldShowPremiumBanner$(userId), - this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog), - ]).pipe( - map( - ([shouldShowBanner, PM24996_ImplementUpgradeFromFreeDialogEnabled]) => - shouldShowBanner && !PM24996_ImplementUpgradeFromFreeDialogEnabled, - ), - ), - ), - ); - // Listen for auth request messages and show banner immediately this.messageListener.allMessages$ .pipe( diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts index 1f27773c467..aad42506777 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts @@ -24,8 +24,6 @@ import { PolicyType } 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 { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -111,11 +109,8 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { collectionTree$: Observable> = combineLatest([ this.filteredCollections$, this.memberOrganizations$, - this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation), ]).pipe( - map(([collections, organizations, defaultCollectionsFlagEnabled]) => - this.buildCollectionTree(collections, organizations, defaultCollectionsFlagEnabled), - ), + map(([collections, organizations]) => this.buildCollectionTree(collections, organizations)), ); cipherTypeTree$: Observable> = this.buildCipherTypeTree(); @@ -133,7 +128,6 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { protected stateProvider: StateProvider, protected collectionService: CollectionService, protected accountService: AccountService, - protected configService: ConfigService, ) {} async getCollectionNodeFromTree(id: string) { @@ -241,18 +235,13 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { protected buildCollectionTree( collections?: CollectionView[], orgs?: Organization[], - defaultCollectionsFlagEnabled?: boolean, ): TreeNode { const headNode = this.getCollectionFilterHead(); if (!collections) { return headNode; } const all: TreeNode[] = []; - - if (defaultCollectionsFlagEnabled) { - collections = sortDefaultCollections(collections, orgs, this.i18nService.collator); - } - + collections = sortDefaultCollections(collections, orgs, this.i18nService.collator); const groupedByOrg = this.collectionService.groupByOrganization(collections); for (const group of groupedByOrg.values()) { diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts index c15dd51a969..f010c529110 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/filter-function.ts @@ -47,7 +47,11 @@ export function createFilterFunction( if (filter.type === "archive" && !CipherViewLikeUtils.isArchived(cipher)) { return false; } - if (filter.type !== "archive" && CipherViewLikeUtils.isArchived(cipher)) { + if ( + filter.type !== "archive" && + filter.type !== "trash" && + CipherViewLikeUtils.isArchived(cipher) + ) { return false; } } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index df1b727154f..cb5332d07d8 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -43,7 +43,9 @@
{{ "premiumSubscriptionEndedDesc" | i18n }}
- {{ "restartPremium" | i18n }} + + {{ "restartPremium" | i18n }} + } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index a5121831304..aa238922eea 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -26,7 +26,6 @@ import { } from "rxjs/operators"; import { - AutomaticUserConfirmationService, CollectionData, CollectionDetailsResponse, CollectionService, @@ -42,6 +41,7 @@ import { ItemTypes, Icon, } from "@bitwarden/assets/svg"; +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 76f130bc4d5..850ab193da8 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Sleutel" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index c794d8f48a0..af84960c021 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "المفتاح" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "رمز التحقق" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index a86dbdb6406..f14a17a60d0 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -2140,7 +2140,7 @@ "message": "Davam etsəniz, hazırkı seansınız bitəcək, təkrar giriş etməyiniz tələb olunacaq. Digər cihazlardakı aktiv seanslar, bir saata qədər aktiv qalmağa davam edə bilər." }, "changePasswordWarning": { - "message": "Parolunuzu dəyişdirdikdən sonra yeni parolunuzla giriş etməli olacaqsınız. Digər cihazlardakı aktiv seanslar bir saat ərzində çıxış sonlandırılacaq." + "message": "Parolunuzu dəyişdirdikdən sonra yeni parolunuzla giriş etməli olacaqsınız. Digər cihazlardakı aktiv sessiyalar bir saat ərzində sonlandırılacaq." }, "emailChanged": { "message": "E-poçt dəyişdirildi" @@ -2634,6 +2634,9 @@ "key": { "message": "Açar" }, + "unnamedKey": { + "message": "Adsız açar" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Doğrulama kodu" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "Arxivinizə təkrar erişmək üçün Premium abunəliyinizi yenidən başladın. Təkrar başlatmazdan əvvəl arxivlənmiş elementin detallarına düzəliş etsəniz, həmin element seyfinizə daşınacaq." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "\"Premium\"u yenidən başlat" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Arxivdən çıxart" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Arxivdən çıxart və saxla" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "İstifadəçi doğrulaması uğursuz oldu." }, + "resizeSideNavigation": { + "message": "Yan naviqasiyanı yeni. ölçüləndir" + }, "recoveryDeleteCiphersTitle": { "message": "Geri qaytarıla bilməyən seyf elementlərini sil" }, diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index f0f595e9b63..d01e7131107 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Ключ" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index 31a91a195ad..dedee053730 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Ключ" }, + "unnamedKey": { + "message": "Ключ без име" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Код за потвърждаване" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "Ако искате отново да получите достъп до архива си, трябва да подновите платения си абонамент. Ако редактирате данните за архивиран елемент преди подновяването, той ще бъде върнат в трезора." }, + "itemRestored": { + "message": "Записът бе възстановен" + }, "restartPremium": { "message": "Подновяване на платения абонамент" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Изваждане от архива" }, + "archived": { + "message": "Архивирано" + }, "unArchiveAndSave": { "message": "Разархивиране и запазване" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "Проверката на потребителя беше неуспешна." }, + "resizeSideNavigation": { + "message": "Преоразмеряване на страничната навигация" + }, "recoveryDeleteCiphersTitle": { "message": "Изтриване на невъзстановимите елементи от трезора" }, diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index a7558a78856..149ac3ebbca 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index f287b526aa2..c162a5464ec 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index 47cd621647a..cb7be2b2bae 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Clau" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Codi de verificació" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 2cb6547205d..a74167ebe0e 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Klíč" }, + "unnamedKey": { + "message": "Nepojmenovaný klíč" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Ověřovací kód" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "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." }, + "itemRestored": { + "message": "Položka byla obnovena" + }, "restartPremium": { "message": "Restartovat Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Odebrat z archivu" }, + "archived": { + "message": "Archivováno" + }, "unArchiveAndSave": { "message": "Odebrat z archivu a uložit" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "Ověření uživatele se nezdařilo." }, + "resizeSideNavigation": { + "message": "Změnit velikost boční navigace" + }, "recoveryDeleteCiphersTitle": { "message": "Smazat neobnovitelné položky trezoru" }, diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index 087d353b3a4..0aaf4d25956 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 53f20b8a8f7..48b77348359 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Nøgle" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Bekræftelseskode" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index ae95c0ca9cb..8e5de82e539 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Schlüssel" }, + "unnamedKey": { + "message": "Unbenannter Schlüssel" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verifizierungscode" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "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." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Premium neu starten" }, @@ -4212,10 +4218,10 @@ } }, "userAcceptedTransfer": { - "message": "Accepted transfer to organization ownership." + "message": "Eigentumsübertragung an die Organisation akzeptiert." }, "userDeclinedTransfer": { - "message": "Revoked for declining transfer to organization ownership." + "message": "Widerrufen wegen Ablehnung der Übertragung an die Organisation." }, "invitedUserId": { "message": "Benutzer $ID$ eingeladen.", @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Nicht mehr archivieren" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Nicht mehr archivieren und speichern" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "Benutzerverifizierung fehlgeschlagen." }, + "resizeSideNavigation": { + "message": "Größe der Seitennavigation ändern" + }, "recoveryDeleteCiphersTitle": { "message": "Nicht-wiederherstellbare Tresor-Einträge löschen" }, diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index 9232935b33c..d273e8d8df4 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Κλειδί" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Κωδικός επαλήθευσης" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 98f847e1d36..8024de21e56 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1188,7 +1188,7 @@ "message": "Me" }, "myItems": { - "message": "My items" + "message": "My Items" }, "myVault": { "message": "My vault" @@ -1749,6 +1749,9 @@ "noMembersInList": { "message": "There are no members to list." }, + "noMembersToExport": { + "message": "There are no members to export." + }, "noEventsInList": { "message": "There are no events to list." }, @@ -2537,6 +2540,9 @@ "enabled": { "message": "Turned on" }, + "optionEnabled": { + "message": "Enabled" + }, "restoreAccess": { "message": "Restore access" }, @@ -3146,6 +3152,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -3272,7 +3281,7 @@ "nextChargeHeader": { "message": "Next Charge" }, - "plan": { + "plan": { "message": "Plan" }, "details": { @@ -3670,9 +3679,6 @@ "defaultCollection": { "message": "Default collection" }, - "myItems": { - "message": "My Items" - }, "getHelp": { "message": "Get help" }, @@ -4497,7 +4503,6 @@ "updateBrowser": { "message": "Update browser" }, - "generatingYourAccessIntelligence": { "message": "Generating your Access Intelligence..." }, @@ -5650,6 +5655,9 @@ "revoked": { "message": "Revoked" }, + "accepted": { + "message": "Accepted" + }, "sendLink": { "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5885,22 +5893,22 @@ "message": "credential lifecycle", "description": "This will be used as a hyperlink" }, - "organizationDataOwnershipWarningTitle":{ + "organizationDataOwnershipWarningTitle": { "message": "Are you sure you want to proceed?" }, - "organizationDataOwnershipWarning1":{ + "organizationDataOwnershipWarning1": { "message": "will remain accessible to members" }, - "organizationDataOwnershipWarning2":{ + "organizationDataOwnershipWarning2": { "message": "will not be automatically selected when creating new items" }, - "organizationDataOwnershipWarning3":{ + "organizationDataOwnershipWarning3": { "message": "cannot be managed from the Admin Console until the user is offboarded" }, - "organizationDataOwnershipWarningContentTop":{ + "organizationDataOwnershipWarningContentTop": { "message": "By turning this policy off, the default collection: " }, - "organizationDataOwnershipWarningContentBottom":{ + "organizationDataOwnershipWarningContentBottom": { "message": "Learn more about the ", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Learn more about the credential lifecycle.'" }, @@ -6024,7 +6032,7 @@ "uriMatchDetectionOptionsLabel": { "message": "Default URI match detection" }, - "invalidUriMatchDefaultPolicySetting": { + "invalidUriMatchDefaultPolicySetting": { "message": "Please select a valid URI match detection option.", "description": "Error message displayed when a user attempts to save URI match detection policy settings with an invalid selection." }, @@ -6308,6 +6316,12 @@ "enrolledAccountRecovery": { "message": "Enrolled in account recovery" }, + "enrolled": { + "message": "Enrolled" + }, + "notEnrolled": { + "message": "Not enrolled" + }, "withdrawAccountRecovery": { "message": "Withdraw from account recovery" }, @@ -8934,7 +8948,7 @@ } }, "accessedSecret": { - "message": "Accessed secret $SECRET_ID$.", + "message": "Accessed secret $SECRET_ID$.", "placeholders": { "secret_id": { "content": "$1", @@ -8942,7 +8956,7 @@ } } }, - "editedSecretWithId": { + "editedSecretWithId": { "message": "Edited a secret with identifier: $SECRET_ID$", "placeholders": { "secret_id": { @@ -8951,7 +8965,7 @@ } } }, - "deletedSecretWithId": { + "deletedSecretWithId": { "message": "Deleted a secret with identifier: $SECRET_ID$", "placeholders": { "secret_id": { @@ -8969,7 +8983,7 @@ } } }, - "restoredSecretWithId": { + "restoredSecretWithId": { "message": "Restored a secret with identifier: $SECRET_ID$", "placeholders": { "secret_id": { @@ -8978,7 +8992,7 @@ } } }, - "createdSecretWithId": { + "createdSecretWithId": { "message": "Created a new secret with identifier: $SECRET_ID$", "placeholders": { "secret_id": { @@ -8988,7 +9002,7 @@ } }, "accessedProjectWithIdentifier": { - "message": "Accessed a project with identifier: $PROJECT_ID$.", + "message": "Accessed a project with identifier: $PROJECT_ID$.", "placeholders": { "project_id": { "content": "$1", @@ -8997,7 +9011,7 @@ } }, "nameUnavailableProjectDeleted": { - "message": "Deleted project Id: $PROJECT_ID$", + "message": "Deleted project Id: $PROJECT_ID$", "placeholders": { "project_id": { "content": "$1", @@ -9006,7 +9020,7 @@ } }, "nameUnavailableSecretDeleted": { - "message": "Deleted secret Id: $SECRET_ID$", + "message": "Deleted secret Id: $SECRET_ID$", "placeholders": { "secret_id": { "content": "$1", @@ -9015,7 +9029,7 @@ } }, "nameUnavailableServiceAccountDeleted": { - "message": "Deleted machine account Id: $SERVICE_ACCOUNT_ID$", + "message": "Deleted machine account Id: $SERVICE_ACCOUNT_ID$", "placeholders": { "service_account_id": { "content": "$1", @@ -9023,7 +9037,7 @@ } } }, - "editedProjectWithId": { + "editedProjectWithId": { "message": "Edited a project with identifier: $PROJECT_ID$", "placeholders": { "project_id": { @@ -9102,7 +9116,7 @@ } } }, - "deletedProjectWithId": { + "deletedProjectWithId": { "message": "Deleted a project with identifier: $PROJECT_ID$", "placeholders": { "project_id": { @@ -9111,7 +9125,7 @@ } } }, - "createdProjectWithId": { + "createdProjectWithId": { "message": "Created a new project with identifier: $PROJECT_ID$", "placeholders": { "project_id": { @@ -9829,15 +9843,15 @@ "message": "Common formats", "description": "Label indicating the most common import formats" }, - "uriMatchDefaultStrategyHint": { + "uriMatchDefaultStrategyHint": { "message": "URI match detection is how Bitwarden identifies autofill suggestions.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, - "regExAdvancedOptionWarning": { + "regExAdvancedOptionWarning": { "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, - "startsWithAdvancedOptionWarning": { + "startsWithAdvancedOptionWarning": { "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, @@ -9845,11 +9859,11 @@ "message": "More about match detection", "description": "Link to match detection docs on warning dialog for advance match strategy" }, - "uriAdvancedOption":{ + "uriAdvancedOption": { "message": "Advanced options", "description": "Advanced option placeholder for uri option component" }, - "warningCapitalized": { + "warningCapitalized": { "message": "Warning", "description": "Warning (should maintain locale-relevant capitalization)" }, @@ -11613,6 +11627,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12187,9 +12204,6 @@ "updateYourEncryptionSettings": { "message": "Update your encryption settings" }, - "updateSettings": { - "message": "Update settings" - }, "algorithm": { "message": "Algorithm" }, @@ -12260,7 +12274,7 @@ } } }, - "removeMasterPasswordForOrgUserKeyConnector":{ + "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, "continueWithLogIn": { @@ -12278,10 +12292,10 @@ "verifyYourOrganization": { "message": "Verify your organization to log in" }, - "organizationVerified":{ + "organizationVerified": { "message": "Organization verified" }, - "domainVerified":{ + "domainVerified": { "message": "Domain verified" }, "leaveOrganizationContent": { @@ -12415,7 +12429,7 @@ } } }, - "howToManageMyVault": { + "howToManageMyVault": { "message": "How do I manage my vault?" }, "transferItemsToOrganizationTitle": { @@ -12445,7 +12459,7 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, - "youHaveBitwardenPremium": { + "youHaveBitwardenPremium": { "message": "You have Bitwarden Premium" }, "viewAndManagePremiumSubscription": { @@ -12463,7 +12477,7 @@ } } }, - "uploadLicenseFile": { + "uploadLicenseFile": { "message": "Upload license file" }, "uploadYourLicenseFile": { @@ -12481,7 +12495,7 @@ } } }, - "alreadyHaveSubscriptionQuestion": { + "alreadyHaveSubscriptionQuestion": { "message": "Already have a subscription?" }, "alreadyHaveSubscriptionSelfHostedMessage": { @@ -12490,7 +12504,81 @@ "viewAllPlans": { "message": "View all plans" }, - "planDescPremium":{ + "planDescPremium": { "message": "Complete online security" + }, + "updatePayment": { + "message": "Update payment" + }, + "weCouldNotProcessYourPayment": { + "message": "We could not process your payment. Please update your payment method or contact the support team for assistance." + }, + "yourSubscriptionHasExpired": { + "message": "Your subscription has expired. Please contact the support team for assistance." + }, + "yourSubscriptionIsScheduledToCancel": { + "message": "Your subscription is scheduled to cancel on $DATE$. You can reinstate it anytime before then.", + "placeholders": { + "date": { + "content": "$1", + "example": "Dec. 22, 2025" + } + } + }, + "premiumShareEvenMore": { + "message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise." + }, + "youHaveAGracePeriod": { + "message": "You have a grace period of $DAYS$ days from your subscription expiration date. Please resolve the past due invoices by $DATE$.", + "placeholders": { + "days": { + "content": "$1", + "example": "14" + }, + "date": { + "content": "$2", + "example": "Dec. 22, 2025" + } + } + }, + "manageInvoices": { + "message": "Manage invoices" + }, + "yourNextChargeIsFor": { + "message": "Your next charge is for" + }, + "dueOn": { + "message": "due on" + }, + "yourSubscriptionWillBeSuspendedOn": { + "message": "Your subscription will be suspended on" + }, + "yourSubscriptionWasSuspendedOn": { + "message": "Your subscription was suspended on" + }, + "yourSubscriptionWillBeCanceledOn": { + "message": "Your subscription will be canceled on" + }, + "yourSubscriptionWasCanceledOn": { + "message": "Your subscription was canceled on" + }, + "storageFull": { + "message": "Storage full" + }, + "storageUsedDescription": { + "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "placeholders": { + "used": { + "content": "$1", + "example": "1" + }, + "available": { + "content": "$2", + "example": "5" + } + } + }, + "storageFullDescription": { + "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." } } diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index 3e25235b9a8..0f2f657e8c4 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index b65b8d40622..f222b832edb 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 7ee4e504035..961aa447cce 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Konigilo" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Aŭtentiga kodo" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 68141386a0e..3ffce558575 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Clave" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Código de verificación" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index d05ba09dfbd..92eed1f84e4 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Võti" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Kinnituskood" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index 39473dbc75c..3ab60e246c0 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Gakoa" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index b85c692e1fe..40fefff18cf 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "کلید" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "کد تأیید" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index 871b256877a..fa5bf0b1f92 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Avain" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Vahvistuskoodi" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index a12aa97341f..f89d13f6804 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index 582d3492a42..c4150f0b1e9 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Clé" }, + "unnamedKey": { + "message": "Clé sans nom" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Code de vérification" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "Pour récupérer l'accès à vos archives, redémarrez votre abonnement Premium. Si vous modifiez les détails d'un élément archivé avant de le redémarrer, il sera déplacé dans votre coffre." }, + "itemRestored": { + "message": "L'élément a été restauré" + }, "restartPremium": { "message": "Redémarrer Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Désarchiver" }, + "archived": { + "message": "Archivé" + }, "unArchiveAndSave": { "message": "Désarchiver et enregistrer" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "La vérification de l'utilisateur a échoué." }, + "resizeSideNavigation": { + "message": "Redimensionner la navigation latérale" + }, "recoveryDeleteCiphersTitle": { "message": "Supprimer les éléments non récupérables du coffre" }, diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index 7c555a3f142..b386b769064 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index c0e1995289e..3774cbe3b3e 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "מפתח" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "קוד אימות" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "הסר מהארכיון" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index 4b578f69e5c..17fc058b781 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index 92f0d020812..c68b3a6279d 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Ključ" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Kôd za provjeru" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "Za ponovni pristup svojoj arhivi, ponovno pokreni Premium pretplatu. Ako urediš detalje arhivirane stavke prije ponovnog pokretanja, ona će biti vraćena u tvoj trezor." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Ponovno Pokreni Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Poništi arhiviranje" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index dff04ac5b3b..3ff717d534c 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Kulcs" }, + "unnamedKey": { + "message": "Névtelen kulcs" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Ellenőrző kód" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "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." }, + "itemRestored": { + "message": "Az elem visszaállításra került." + }, "restartPremium": { "message": "Prémium előfizetés újraindítása" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Visszavétel archívumból" }, + "archived": { + "message": "Archiválva" + }, "unArchiveAndSave": { "message": "Archiválás visszavonása és mentés" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "A felhasználó ellenőrzése sikertelen volt." }, + "resizeSideNavigation": { + "message": "Oldalnavigáció átméretezés" + }, "recoveryDeleteCiphersTitle": { "message": "Helyreállíthatatlan széf elemek törlése" }, diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index e349f4180b9..73a1d220918 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Kunci" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Kode verifikasi" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index b4942bd75f6..b9a2b98dc18 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Chiave" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Codice di verifica" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "Per recuperare l'accesso al tuo archivio, riavvia il tuo abbonamento Premium. Se modifichi i dettagli di un elemento archiviato prima del riavvio, sarà spostato nella tua cassaforte." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Riavvia Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Togli dall'archivio" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Togli dall'archivio e salva" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "Verifica dell'utente non riuscita." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Elimina gli oggetti della cassaforte non recuperabili" }, diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index 20fa1137002..2e290f0e4b9 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "キー" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "認証コード" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index 4a6927d9f3a..4c6c8a6572c 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index f570d355369..09f111c30c5 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index 94237cb1028..b0e03d029cf 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "ಕೀ" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index c0471507369..a8eb53841ec 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "키" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index eb39c3b8eee..dc6ddbce784 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Atslēga" }, + "unnamedKey": { + "message": "Nenodēvēta atslēga" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Apliecinājuma kods" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "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ā." }, + "itemRestored": { + "message": "Vienums tika atjaunots" + }, "restartPremium": { "message": "Atsākt Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Atcelt arhivēšanu" }, + "archived": { + "message": "Arhivēts" + }, "unArchiveAndSave": { "message": "Atcelt arhivēšanu un saglabāt" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "Lietotāja apliecināšana neizdevās." }, + "resizeSideNavigation": { + "message": "Mainīt sānu pārvietošanās joslas izmēru" + }, "recoveryDeleteCiphersTitle": { "message": "Izdzēst neatkopjamos glabātavas vienumus" }, diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index 1dc40cce28b..dcf3e950dfe 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index b51c66468c3..70771c9fbed 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index f570d355369..09f111c30c5 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index dd76b11f8be..8bf79f74f07 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Nøkkel" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verifiseringskode" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index cfd5c682a99..75f4b5524c3 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index 61538a1bb2a..f2f2cebb236 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Sleutel" }, + "unnamedKey": { + "message": "Naamloze sleutel" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verificatiecode" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "Herstart je Premium-abonnement om toegang tot je archief te krijgen. Als je de details wijzigt voor een gearchiveerd item voor het opnieuw opstarten, zal het terug naar je kluis worden verplaatst." }, + "itemRestored": { + "message": "Item is hersteld" + }, "restartPremium": { "message": "Premium herstarten" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Dearchiveren" }, + "archived": { + "message": "Gearchiveerd" + }, "unArchiveAndSave": { "message": "Dearchiveren en opslaan" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Formaat zijnavigatie wijzigen" + }, "recoveryDeleteCiphersTitle": { "message": "Onherstelbare kluisitems verwijderen" }, diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index d7cfdd21d2f..1e4f376f083 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Nykel" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index f570d355369..09f111c30c5 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 93843ca45b7..272b53eac23 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Klucz" }, + "unnamedKey": { + "message": "Klucz bez nazwy" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Kod weryfikacyjny" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Zmień rozmiar nawigacji bocznej" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 28e95ed6379..5f5d4500bef 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -551,7 +551,7 @@ } }, "autoFillOnPageLoad": { - "message": "Preencher automaticamente ao carregar a página?" + "message": "Preencher ao carregar a página?" }, "number": { "message": "Número" @@ -1050,7 +1050,7 @@ "description": "Header for view SSH key item type" }, "new": { - "message": "Novo", + "message": "Criar", "description": "for adding new items" }, "item": { @@ -2634,6 +2634,9 @@ "key": { "message": "Chave" }, + "unnamedKey": { + "message": "Chave sem nome" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Código de verificação" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "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." }, + "itemRestored": { + "message": "O item foi restaurado" + }, "restartPremium": { "message": "Retomar Premium" }, @@ -4215,7 +4221,7 @@ "message": "Aceitou a transferência da propriedade da organização." }, "userDeclinedTransfer": { - "message": "Não aceitou a transferência da propriedade da organização." + "message": "Revogado por não aceitar a transferência da propriedade da organização." }, "invitedUserId": { "message": "Convidou o usuário $ID$.", @@ -9777,7 +9783,7 @@ "message": "Instalar extensão de navegador" }, "installBrowserExtensionDetails": { - "message": "Use a extensão para salvar credenciais e preencher automaticamente formulários de forma rápida, sem abrir o aplicativo web." + "message": "Use a extensão para salvar credenciais e preencher formulários de forma rápida, sem abrir o aplicativo web." }, "projectAccessUpdated": { "message": "Acesso ao projeto atualizado" @@ -11075,7 +11081,7 @@ "message": "Use campos ocultos para dados sensíveis como senhas" }, "checkBoxHelpText": { - "message": "Use caixas de seleção se gostaria de preencher automaticamente a caixa de seleção de um formulário, como um lembrar e-mail" + "message": "Use caixas de seleção se gostaria de preencher a caixa de seleção de um formulário, como um lembrar e-mail" }, "linkedHelpText": { "message": "Use um campo vinculado quando estiver enfrentando problemas com o preenchimento automático em um site específico." @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Desarquivar" }, + "archived": { + "message": "Arquivados" + }, "unArchiveAndSave": { "message": "Desarquivar e salvar" }, @@ -11743,7 +11752,7 @@ "message": "Adicionar mais tarde" }, "cannotAutofillPasswordsWithoutExtensionTitle": { - "message": "Você não pode preencher senhas automaticamente sem a extensão de navegador" + "message": "Você não pode preencher senhas sem a extensão de navegador" }, "cannotAutofillPasswordsWithoutExtensionDesc": { "message": "Você tem certeza de que não quer adicionar a extensão agora?" @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "Falha na verificação do usuário." }, + "resizeSideNavigation": { + "message": "Redimensionar navegação lateral" + }, "recoveryDeleteCiphersTitle": { "message": "Apagar itens não recuperáveis do cofre" }, diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index 929be5c7456..ae6d11eab21 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Chave" }, + "unnamedKey": { + "message": "Chave sem nome" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Código de verificação" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "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." }, + "itemRestored": { + "message": "O item foi restaurado" + }, "restartPremium": { "message": "Reiniciar Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Desarquivar" }, + "archived": { + "message": "Arquivado" + }, "unArchiveAndSave": { "message": "Desarquivar e guardar" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "Falha na verificação do utilizador." }, + "resizeSideNavigation": { + "message": "Redimensionar navegação lateral" + }, "recoveryDeleteCiphersTitle": { "message": "Eliminar itens irrecuperáveis do cofre" }, diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index 80ee2d5e9b6..490b33dd8ae 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Cheie" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index f82aa6d4417..5fe8d27139a 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Ключ" }, + "unnamedKey": { + "message": "Безымянный ключ" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Код подтверждения" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "Чтобы восстановить доступ к своему архиву, подключите подписку Премиум повторно. Если вы измените сведения об архивированном элементе перед переподключением, он будет перемещен обратно в ваше хранилище." }, + "itemRestored": { + "message": "Элемент восстановлен" + }, "restartPremium": { "message": "Переподключить Премиум" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Разархивировать" }, + "archived": { + "message": "Архивирован" + }, "unArchiveAndSave": { "message": "Разархивировать и сохранить" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "Проверка пользователя не удалась." }, + "resizeSideNavigation": { + "message": "Изменить размер боковой навигации" + }, "recoveryDeleteCiphersTitle": { "message": "Удалить невосстановимые элементы хранилища" }, diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index e4166c07db2..34f5fdb6a56 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index ea2d12bdb2c..b881394a479 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Kľúč" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Overovací kód" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Zrušiť archiváciu" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "Zlyhalo overenie používateľa." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index 1f880d87412..edd02eaf75d 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Ključ" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index 3ebf0057bb9..cd0749da2ad 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Ključ" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/sr_CY/messages.json b/apps/web/src/locales/sr_CY/messages.json index b7934ec62fb..526557d97e3 100644 --- a/apps/web/src/locales/sr_CY/messages.json +++ b/apps/web/src/locales/sr_CY/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Кључ" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Верификациони кôд" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Врати из архиве" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index c428b1561ed..b39eeaf1fac 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Nyckel" }, + "unnamedKey": { + "message": "Namnlös nyckel" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verifieringskod" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "För att återfå åtkomst till ditt arkiv, starta om Premium-prenumerationen. Om du redigerar detaljer för ett arkiverat objekt innan du startar om kommer det att flyttas tillbaka till ditt valv." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Starta om Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Avarkivera" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Avarkivera och spara" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "Verifiering av användare misslyckades." }, + "resizeSideNavigation": { + "message": "Ändra storlek på sidnavigering" + }, "recoveryDeleteCiphersTitle": { "message": "Ta bort oåterkalleliga valvobjekt" }, diff --git a/apps/web/src/locales/ta/messages.json b/apps/web/src/locales/ta/messages.json index 5ad16f4e27e..348147d044c 100644 --- a/apps/web/src/locales/ta/messages.json +++ b/apps/web/src/locales/ta/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "கீ" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "சரிபார்ப்புக் குறியீடு" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index f570d355369..09f111c30c5 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index 8efc54bf052..355dfda0f49 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Key" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Verification code" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index 27e1740fca7..fe000f73ed7 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Anahtar" }, + "unnamedKey": { + "message": "Adsız anahtar" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Doğrulama kodu" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "Arşivinize yeniden erişim kazanmak 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." }, + "itemRestored": { + "message": "Kayıt geri yüklendi" + }, "restartPremium": { "message": "Premium’u yeniden başlat" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Arşivden çıkar" }, + "archived": { + "message": "Arşivlendi" + }, "unArchiveAndSave": { "message": "Arşivden çıkar ve kaydet" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "Kullanıcı doğrulaması başarısız oldu." }, + "resizeSideNavigation": { + "message": "Kenar menüsünü yeniden boyutlandır" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index f604d38059f..e9d1a3e1551 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Ключ" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Код підтвердження" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Restart Premium" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "User verification failed." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Delete unrecoverable vault items" }, diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index 1fb9e911225..3e1f74bccba 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "Khóa" }, + "unnamedKey": { + "message": "Unnamed key" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "Mã xác minh" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "Để lấy lại quyền truy cập vào lưu trữ của bạn, hãy khởi động lại gói đăng ký Cao cấp. Nếu bạn chỉnh sửa chi tiết cho một mục đã lưu trữ trước khi khởi động lại, mục đó sẽ được chuyển trở lại kho của bạn." }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "Khởi động lại gói Cao cấp" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "Hủy lưu trữ" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "Unarchive and save" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "Xác minh người dùng thất bại." }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "Xóa các mục kho không thể khôi phục" }, diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index b41635b948c..f6b2adfc3da 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "密钥" }, + "unnamedKey": { + "message": "未命名的密钥" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "验证码" }, @@ -3141,7 +3144,10 @@ "message": "您的高级版订阅已结束" }, "premiumSubscriptionEndedDesc": { - "message": "要重新获取存档的访问权限,请重启您的高级版订阅。如果您在重启前编辑了存档项目的详细信息,它将被移回您的密码库中。" + "message": "要重新获取归档内容的访问权限,请重启您的高级版订阅。如果您在重启前编辑了某个已归档项目的详细信息,它将被移回您的密码库中。" + }, + "itemRestored": { + "message": "项目已恢复" }, "restartPremium": { "message": "重启高级版" @@ -3671,7 +3677,7 @@ "message": "获取帮助" }, "getApps": { - "message": "获取应用" + "message": "获取 App" }, "loggedInAs": { "message": "已登录为" @@ -5422,7 +5428,7 @@ } }, "vaultTimeoutLogOutConfirmation": { - "message": "超时后注销将解除对密码库的所有访问权限,并需要进行在线身份验证。确定使用此设置吗?" + "message": "超时后注销账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定要使用此设置吗?" }, "vaultTimeoutLogOutConfirmationTitle": { "message": "超时动作确认" @@ -6118,7 +6124,7 @@ "message": "组织策略已阻止将项目导入您的个人密码库。" }, "personalOwnershipCheckboxDesc": { - "message": "移除组织用户的个人所有权" + "message": "禁用组织用户的个人所有权" }, "send": { "message": "Send", @@ -6359,7 +6365,7 @@ "message": "重置密码" }, "resetPasswordLoggedOutWarning": { - "message": "继续操作会将 $NAME$ 登出当前会话,要求他们重新登录。在其他设备上的活动会话可能继续活动长达一个小时。", + "message": "继续操作会将 $NAME$ 登出当前会话,并要求他们重新登录。在其他设备上的活动会话可能继续活动长达一个小时。", "placeholders": { "name": { "content": "$1", @@ -6368,7 +6374,7 @@ } }, "emergencyAccessLoggedOutWarning": { - "message": "继续操作会将 $NAME$ 登出当前会话,要求他们重新登录。在其他设备上的活动会话可能继续活动长达一个小时。", + "message": "继续操作会将 $NAME$ 登出当前会话,并要求他们重新登录。在其他设备上的活动会话可能继续活动长达一个小时。", "placeholders": { "name": { "content": "$1", @@ -8878,7 +8884,7 @@ "message": "群组/用户" }, "kdfSettingsChangeLogoutWarning": { - "message": "接下来将会注销您所有的活动会话。您需要重新登录并完成两步登录(如果有)。我们建议您在更改加密设置前导出密码库,以防止数据丢失。" + "message": "继续操作将会注销您所有的活动会话。您需要重新登录并完成两步登录(如果有)。我们建议您在更改加密设置前导出密码库,以防止数据丢失。" }, "secretsManager": { "message": "机密管理器" @@ -10545,7 +10551,7 @@ "description": "Button text to navigate back" }, "removeItem": { - "message": "删除 $NAME$", + "message": "移除 $NAME$", "description": "Remove a selected option, such as a folder or collection", "placeholders": { "name": { @@ -11170,7 +11176,7 @@ } }, "organizationUserDeletedDesc": { - "message": "该用户已从组织中删除,所有关联的用户数据也已删除。" + "message": "该用户已从组织中移除,所有关联的用户数据也已被删除。" }, "deletedUserIdEventMessage": { "message": "删除了用户 $ID$", @@ -11606,6 +11612,9 @@ "unArchive": { "message": "取消归档" }, + "archived": { + "message": "已归档" + }, "unArchiveAndSave": { "message": "取消归档并保存" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "用户验证失败。" }, + "resizeSideNavigation": { + "message": "调整侧边导航栏大小" + }, "recoveryDeleteCiphersTitle": { "message": "删除无法恢复的密码库项目" }, diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index 4fbf08c28a7..9b44b0c3b27 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -2634,6 +2634,9 @@ "key": { "message": "金鑰" }, + "unnamedKey": { + "message": "未命名金鑰" + }, "twoStepAuthenticatorEnterCodeV2": { "message": "驗證碼" }, @@ -3143,6 +3146,9 @@ "premiumSubscriptionEndedDesc": { "message": "若要重新存取您的封存項目,請重新啟用進階版訂閱。若您在重新啟用前編輯封存項目的詳細資料,它將會被移回您的密碼庫。" }, + "itemRestored": { + "message": "Item has been restored" + }, "restartPremium": { "message": "重新啟用進階版" }, @@ -11606,6 +11612,9 @@ "unArchive": { "message": "取消封存" }, + "archived": { + "message": "Archived" + }, "unArchiveAndSave": { "message": "取消封存並儲存" }, @@ -12298,6 +12307,9 @@ "userVerificationFailed": { "message": "使用者驗證失敗。" }, + "resizeSideNavigation": { + "message": "Resize side navigation" + }, "recoveryDeleteCiphersTitle": { "message": "刪除無法復原的密碼庫項目" }, diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/services/README.md b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/README.md new file mode 100644 index 00000000000..1796b0db071 --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/services/README.md @@ -0,0 +1,358 @@ +# Adding a New Integration Configuration and Template + +This guide explains how to add a new integration type (e.g., Datadog, Splunk HEC) to the organization integrations system. + +## Step 1: Define the Configuration Class + +Create a new configuration class that implements `OrgIntegrationConfiguration`: + +```typescript +export class MyServiceConfiguration implements OrgIntegrationConfiguration { + // Required: Specify which service this configuration is for + bw_serviceName: OrganizationIntegrationServiceName; + + // Add service-specific properties (e.g., uri, apiKey, token) + uri: string; + apiKey: string; + + constructor(uri: string, apiKey: string, bw_serviceName: OrganizationIntegrationServiceName) { + this.uri = uri; + this.apiKey = apiKey; + this.bw_serviceName = bw_serviceName; + } + + // Required: Serialize configuration to JSON string for API transmission + // Property names should match PascalCase for backend compatibility + // Example: "Uri", "ApiKey" - the backend expects PascalCase keys + toString(): string { + return JSON.stringify({ + Uri: this.uri, + ApiKey: this.apiKey, + bw_serviceName: this.bw_serviceName, + }); + } +} +``` + +**Required Interface Properties:** + +- `bw_serviceName: OrganizationIntegrationServiceName` - Identifies the external service +- `toString(): string` - Serializes configuration for API storage + +## Step 2: Define the Template Class + +Create a template class that implements `OrgIntegrationTemplate`: + +```typescript +export class MyServiceTemplate implements OrgIntegrationTemplate { + // Required: Specify which service this template is for + bw_serviceName: OrganizationIntegrationServiceName; + + // Add template-specific properties with placeholders (e.g., #CipherId#, #UserEmail#) + // These placeholders will be replaced with actual values at runtime + + constructor(service: OrganizationIntegrationServiceName) { + this.bw_serviceName = service; + } + + // Required: Serialize template to JSON string + // Define the structure of data that will be sent to the external service + toString(): string { + return JSON.stringify({ + bw_serviceName: this.bw_serviceName, + event: { + type: "#Type#", + userId: "#UserId#", + // ... other placeholders + }, + }); + } +} +``` + +**Required Interface Properties:** + +- `bw_serviceName: OrganizationIntegrationServiceName` - Identifies the external service +- `toString(): string` - Serializes template structure with placeholders + +## Step 3: Update OrganizationIntegrationType + +Add your new integration type to the enum: + +```typescript +export const OrganizationIntegrationType = Object.freeze({ + // ... existing types + MyService: 7, +} as const); +``` + +## Step 4: Extend OrgIntegrationBuilder + +The `OrgIntegrationBuilder` is the central factory for creating and deserializing integration configurations and templates. +It provides a consistent API for the `OrganizationIntegrationService` to work with different integration types. + +Add four methods to `OrgIntegrationBuilder`: + +### 4a. Add a static factory method for configuration: + +```typescript +static buildMyServiceConfiguration( + uri: string, + apiKey: string, + bw_serviceName: OrganizationIntegrationServiceName +): OrgIntegrationConfiguration { + return new MyServiceConfiguration(uri, apiKey, bw_serviceName); +} +``` + +### 4b. Add a static factory method for template: + +```typescript +static buildMyServiceTemplate( + bw_serviceName: OrganizationIntegrationServiceName +): OrgIntegrationTemplate { + return new MyServiceTemplate(bw_serviceName); +} +``` + +### 4c. Add a case to `buildConfiguration()` switch statement: + +```typescript +case OrganizationIntegrationType.MyService: { + const config = this.convertToJson(configuration); + return this.buildMyServiceConfiguration(config.uri, config.apiKey, config.bw_serviceName); +} +``` + +This allows deserialization of JSON configuration strings from the API into typed objects. + +### 4d. Add a case to `buildTemplate()` switch statement: + +```typescript +case OrganizationIntegrationType.MyService: { + const template = this.convertToJson(template); + return this.buildMyServiceTemplate(template.bw_serviceName); +} +``` + +This allows deserialization of JSON template strings from the API into typed objects. + +## How This Facilitates OrganizationIntegrationService + +The `OrgIntegrationBuilder` acts as an abstraction layer that enables the `OrganizationIntegrationService` to: + +1. **Save/Update Operations**: Accept strongly-typed configuration and template objects, serialize them via `toString()`, + and send to the API as JSON strings. + +2. **Load Operations**: Receive JSON strings from the API, use `buildConfiguration()` and `buildTemplate()` to + deserialize them into strongly-typed objects through the builder's factory methods. + +3. **Type Safety**: Work with typed domain models (`OrgIntegrationConfiguration`, `OrgIntegrationTemplate`) without + knowing the specific implementation details of each integration type. + +4. **Extensibility**: Add new integration types without modifying the service layer logic. The service only needs to + call the builder's methods, which internally route to the correct implementation based on `OrganizationIntegrationType`. + +5. **Property Normalization**: The builder's `normalizePropertyCase()` method handles conversion between PascalCase + (backend) and camelCase (frontend), ensuring seamless data flow regardless of API naming conventions. + +The service uses these capabilities in methods like `save()`, `update()`, and `mapResponsesToOrganizationIntegration()` +to manage the complete lifecycle of integration configurations and templates. + +## Step 5: Add Service Name to OrganizationIntegrationServiceName + +If you're adding a new external service (not just a new integration type for an existing service), +add it to the `OrganizationIntegrationServiceName` enum in `organization-integration-service-type.ts`: + +```typescript +export const OrganizationIntegrationServiceName = Object.freeze({ + CrowdStrike: "CrowdStrike", + Datadog: "Datadog", + MyService: "MyService", // Add your new service +} as const); +``` + +This identifies the external service your integration connects to. The `bw_serviceName` property in your +configuration and template classes should use a value from this enum. + +## Step 6: File Organization + +Place your new files in the following directories: + +- **Configuration classes**: `models/configuration/` + - Example: `models/configuration/myservice-configuration.ts` +- **Template classes**: `models/integration-configuration-config/configuration-template/` + - Example: `models/integration-configuration-config/configuration-template/myservice-template.ts` + +This organization keeps related files grouped and maintains consistency with existing integrations. + +## Important Conventions + +### Template Placeholders + +Templates support standardized placeholders that are replaced with actual values at runtime. +Use the following format with hashtags: + +**Common placeholders**: + +- `#EventMessage#` - Full event message +- `#Type#` - Event type +- `#CipherId#` - Cipher/item identifier +- `#CollectionId#` - Collection identifier +- `#GroupId#` - Group identifier +- `#PolicyId#` - Policy identifier +- `#UserId#` - User identifier +- `#ActingUserId#` - User performing the action +- `#UserName#` - User's name +- `#UserEmail#` - User's email +- `#ActingUserName#` - Acting user's name +- `#ActingUserEmail#` - Acting user's email +- `#DateIso8601#` - ISO 8601 formatted date +- `#DeviceType#` - Device type +- `#IpAddress#` - IP address +- `#SecretId#` - Secret identifier +- `#ProjectId#` - Project identifier +- `#ServiceAccountId#` - Service account identifier + +These placeholders are processed server-side when events are sent to the external service. +**_Also, these placeholders are determined by the server-side implementation, so ensure your template matches the expected format._** + +## Step 7: Add Tests + +Add comprehensive tests for your new integration in three test files: + +### 7a. Integration Service Tests + +Add tests in `organization-integration-service.spec.ts`: + +```typescript +describe("MyService integration", () => { + it("should save a new MyService integration successfully", async () => { + const config = OrgIntegrationBuilder.buildMyServiceConfiguration( + "https://test.myservice.com", + "test-api-key", + OrganizationIntegrationServiceName.MyService, + ); + const template = OrgIntegrationBuilder.buildMyServiceTemplate( + OrganizationIntegrationServiceName.MyService, + ); + // ... test implementation + }); +}); +``` + +The implementation should cover save, update, delete, and load operations. +This is all that is required to make a new integration type functional within the service. + +--- + +## Understanding the Architecture + +**Workflow**: + +1. Call `setOrganizationId(orgId)` to load integrations for an organization +2. Subscribe to `integrations$` to receive the loaded integrations +3. Any save/update/delete operations automatically update `integrations$` + +The service uses `BehaviorSubject` internally to manage state and emit updates to all subscribers. + +### Error Handling Pattern + +All modification operations (`save()`, `update()`, `delete()`) return `IntegrationModificationResult`: + +```typescript +type IntegrationModificationResult = { + success: boolean; // Operation succeeded + mustBeOwner: boolean; // If false, permission denied (404) - user must be organization owner +}; +``` + +This pattern allows the UI to provide specific feedback when users lack necessary permissions. + +### Configuration vs Template + +Understanding the distinction between these two concepts is crucial: + +**Configuration (`OrgIntegrationConfiguration`)**: + +- Contains authentication and connection details +- Example: API URLs, tokens, API keys, authentication schemes +- Stored in the `Integration` record +- Usually contains sensitive data +- One per integration + +**Template (`OrgIntegrationTemplate`)**: + +- Defines the structure and format of event data +- Contains placeholders like `#UserId#`, `#EventMessage#` +- Stored in the `IntegrationConfiguration` record +- No sensitive data +- Specifies how Bitwarden events map to external service format +- One per integration (current implementation) + +When an event occurs, the system: + +1. Uses the **Configuration** to know where and how to send data +2. Uses the **Template** to format the event data for that specific service + +## Example: Complete Integration + +Here's a minimal example showing all pieces working together: + +```typescript +// 1. Configuration +export class ExampleConfiguration implements OrgIntegrationConfiguration { + uri: string; + apiKey: string; + bw_serviceName: OrganizationIntegrationServiceName; + + constructor(uri: string, apiKey: string, bw_serviceName: OrganizationIntegrationServiceName) { + this.uri = uri; + this.apiKey = apiKey; + this.bw_serviceName = bw_serviceName; + } + + toString(): string { + return JSON.stringify({ + Uri: this.uri, + ApiKey: this.apiKey, + bw_serviceName: this.bw_serviceName, + }); + } +} + +// 2. Template +export class ExampleTemplate implements OrgIntegrationTemplate { + bw_serviceName: OrganizationIntegrationServiceName; + + constructor(bw_serviceName: OrganizationIntegrationServiceName) { + this.bw_serviceName = bw_serviceName; + } + + toString(): string { + return JSON.stringify({ + bw_serviceName: this.bw_serviceName, + event: { + type: "#Type#", + user: "#UserEmail#", + timestamp: "#DateIso8601#", + }, + }); + } +} + +// 3. Usage in OrganizationIntegrationService +const config = OrgIntegrationBuilder.buildExampleConfiguration( + "https://api.example.com", + "secret-key", + OrganizationIntegrationServiceName.Example, +); + +const template = OrgIntegrationBuilder.buildExampleTemplate( + OrganizationIntegrationServiceName.Example, +); + +await service.save(orgId, OrganizationIntegrationType.Example, config, template); +``` + +This creates a complete integration that can authenticate with the external service and format event data appropriately. diff --git a/jest.config.js b/jest.config.js index 300246a692b..5ea699febff 100644 --- a/jest.config.js +++ b/jest.config.js @@ -59,6 +59,7 @@ module.exports = { "/libs/tools/send/send-ui/jest.config.js", "/libs/user-core/jest.config.js", "/libs/vault/jest.config.js", + "/libs/auto-confirm/jest.config.js", "/libs/subscription/jest.config.js", "/libs/user-crypto-management/jest.config.js", ], diff --git a/libs/admin-console/src/common/index.ts b/libs/admin-console/src/common/index.ts index 37f79d56256..5178805cec5 100644 --- a/libs/admin-console/src/common/index.ts +++ b/libs/admin-console/src/common/index.ts @@ -1,3 +1,2 @@ -export * from "./auto-confirm"; export * from "./collections"; export * from "./organization-user"; diff --git a/libs/angular/src/admin-console/guards/index.ts b/libs/angular/src/admin-console/guards/index.ts new file mode 100644 index 00000000000..71f34285761 --- /dev/null +++ b/libs/angular/src/admin-console/guards/index.ts @@ -0,0 +1 @@ +export * from "./org-policy.guard"; diff --git a/apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts b/libs/angular/src/admin-console/guards/org-policy.guard.ts similarity index 100% rename from apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts rename to libs/angular/src/admin-console/guards/org-policy.guard.ts diff --git a/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.spec.ts b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.spec.ts deleted file mode 100644 index 018b1ce2547..00000000000 --- a/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { TestBed } from "@angular/core/testing"; - -import { DefaultLoginApprovalDialogComponentService } from "./default-login-approval-dialog-component.service"; -import { LoginApprovalDialogComponent } from "./login-approval-dialog.component"; - -describe("DefaultLoginApprovalDialogComponentService", () => { - let service: DefaultLoginApprovalDialogComponentService; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [DefaultLoginApprovalDialogComponentService], - }); - - service = TestBed.inject(DefaultLoginApprovalDialogComponentService); - }); - - it("is created successfully", () => { - expect(service).toBeTruthy(); - }); - - it("has showLoginRequestedAlertIfWindowNotVisible method that is a no-op", async () => { - const loginApprovalDialogComponent = {} as LoginApprovalDialogComponent; - - const result = await service.showLoginRequestedAlertIfWindowNotVisible( - loginApprovalDialogComponent.email, - ); - - expect(result).toBeUndefined(); - }); -}); diff --git a/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts deleted file mode 100644 index 4a9a37fd0de..00000000000 --- a/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction"; - -/** - * Default implementation of the LoginApprovalDialogComponentServiceAbstraction. - */ -export class DefaultLoginApprovalDialogComponentService implements LoginApprovalDialogComponentServiceAbstraction { - /** - * No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method. - * @returns - */ - async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise { - return; - } -} diff --git a/libs/angular/src/auth/login-approval/index.ts b/libs/angular/src/auth/login-approval/index.ts index 7b34b17d56b..21cde4df742 100644 --- a/libs/angular/src/auth/login-approval/index.ts +++ b/libs/angular/src/auth/login-approval/index.ts @@ -1,3 +1 @@ export * from "./login-approval-dialog.component"; -export * from "./login-approval-dialog-component.service.abstraction"; -export * from "./default-login-approval-dialog-component.service"; diff --git a/libs/angular/src/auth/login-approval/login-approval-dialog-component.service.abstraction.ts b/libs/angular/src/auth/login-approval/login-approval-dialog-component.service.abstraction.ts deleted file mode 100644 index f29311402a7..00000000000 --- a/libs/angular/src/auth/login-approval/login-approval-dialog-component.service.abstraction.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Abstraction for the LoginApprovalDialogComponent service. - */ -export abstract class LoginApprovalDialogComponentServiceAbstraction { - /** - * Shows a login requested alert if the window is not visible. - */ - abstract showLoginRequestedAlertIfWindowNotVisible: (email?: string) => Promise; -} diff --git a/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts b/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts index 4dc7522c1b8..0640c32ea4c 100644 --- a/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts +++ b/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts @@ -16,7 +16,6 @@ import { UserId } from "@bitwarden/common/types/guid"; import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; -import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction"; import { LoginApprovalDialogComponent } from "./login-approval-dialog.component"; describe("LoginApprovalDialogComponent", () => { @@ -69,10 +68,6 @@ describe("LoginApprovalDialogComponent", () => { { provide: LogService, useValue: logService }, { provide: ToastService, useValue: toastService }, { provide: ValidationService, useValue: validationService }, - { - provide: LoginApprovalDialogComponentServiceAbstraction, - useValue: mock(), - }, ], }).compileComponents(); diff --git a/libs/angular/src/auth/login-approval/login-approval-dialog.component.ts b/libs/angular/src/auth/login-approval/login-approval-dialog.component.ts index 35333c43536..54906047535 100644 --- a/libs/angular/src/auth/login-approval/login-approval-dialog.component.ts +++ b/libs/angular/src/auth/login-approval/login-approval-dialog.component.ts @@ -24,8 +24,6 @@ import { } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; -import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction"; - const RequestTimeOut = 60000 * 15; // 15 Minutes const RequestTimeUpdate = 60000 * 5; // 5 Minutes @@ -57,7 +55,6 @@ export class LoginApprovalDialogComponent implements OnInit, OnDestroy { private devicesService: DevicesServiceAbstraction, private dialogRef: DialogRef, private i18nService: I18nService, - private loginApprovalDialogComponentService: LoginApprovalDialogComponentServiceAbstraction, private logService: LogService, private toastService: ToastService, private validationService: ValidationService, @@ -113,10 +110,6 @@ export class LoginApprovalDialogComponent implements OnInit, OnDestroy { this.updateTimeText(); }, RequestTimeUpdate); - await this.loginApprovalDialogComponentService.showLoginRequestedAlertIfWindowNotVisible( - this.email, - ); - this.loading = false; } diff --git a/libs/angular/src/key-management/encrypted-migration/prompt-migration-password.component.ts b/libs/angular/src/key-management/encrypted-migration/prompt-migration-password.component.ts index 59fec1a6f70..91e1ef56e13 100644 --- a/libs/angular/src/key-management/encrypted-migration/prompt-migration-password.component.ts +++ b/libs/angular/src/key-management/encrypted-migration/prompt-migration-password.component.ts @@ -6,6 +6,7 @@ import { filter, firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LinkModule, AsyncActionsModule, @@ -15,6 +16,7 @@ import { DialogService, FormFieldModule, IconButtonModule, + ToastService, } from "@bitwarden/components"; /** @@ -40,6 +42,8 @@ export class PromptMigrationPasswordComponent { private formBuilder = inject(FormBuilder); private masterPasswordUnlockService = inject(MasterPasswordUnlockService); private accountService = inject(AccountService); + private toastService = inject(ToastService); + private i18nService = inject(I18nService); migrationPasswordForm = this.formBuilder.group({ masterPassword: ["", [Validators.required]], @@ -73,6 +77,10 @@ export class PromptMigrationPasswordComponent { userId, )) ) { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("incorrectPassword"), + }); return; } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 7899ff5281a..5eaac4033eb 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -94,7 +94,7 @@ import { InternalAccountService, } from "@bitwarden/common/auth/abstractions/account.service"; import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/auth/abstractions/anonymous-hub.service"; -import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; @@ -112,7 +112,7 @@ import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/aut import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service"; -import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service"; +import { DefaultAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/default-auth-request-answering.service"; import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; @@ -397,8 +397,6 @@ import { VaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; -import { DefaultLoginApprovalDialogComponentService } from "../auth/login-approval/default-login-approval-dialog-component.service"; -import { LoginApprovalDialogComponentServiceAbstraction } from "../auth/login-approval/login-approval-dialog-component.service.abstraction"; import { DefaultSetInitialPasswordService } from "../auth/password-management/set-initial-password/default-set-initial-password.service.implementation"; import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction"; import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction"; @@ -1040,9 +1038,15 @@ const safeProviders: SafeProvider[] = [ deps: [StateProvider], }), safeProvider({ - provide: AuthRequestAnsweringServiceAbstraction, - useClass: NoopAuthRequestAnsweringService, - deps: [], + provide: AuthRequestAnsweringService, + useClass: DefaultAuthRequestAnsweringService, + deps: [ + AccountServiceAbstraction, + AuthServiceAbstraction, + MasterPasswordServiceAbstraction, + MessagingServiceAbstraction, + PendingAuthRequestsStateService, + ], }), safeProvider({ provide: ServerNotificationsService, @@ -1060,7 +1064,7 @@ const safeProviders: SafeProvider[] = [ SignalRConnectionService, AuthServiceAbstraction, WebPushConnectionService, - AuthRequestAnsweringServiceAbstraction, + AuthRequestAnsweringService, ConfigService, InternalPolicyService, ], @@ -1666,11 +1670,6 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultSendPasswordService, deps: [CryptoFunctionServiceAbstraction], }), - safeProvider({ - provide: LoginApprovalDialogComponentServiceAbstraction, - useClass: DefaultLoginApprovalDialogComponentService, - deps: [], - }), safeProvider({ provide: LoginDecryptionOptionsService, useClass: DefaultLoginDecryptionOptionsService, diff --git a/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.spec.ts b/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.spec.ts new file mode 100644 index 00000000000..4e8d1ed3d1a --- /dev/null +++ b/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.spec.ts @@ -0,0 +1,226 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/user-core"; + +import { FakeStateProvider, mockAccountServiceWith } from "../../../../../../libs/common/spec"; +import { NUDGE_DISMISSED_DISK_KEY, NudgeType } from "../nudges.service"; + +import { AutoConfirmNudgeService } from "./auto-confirm-nudge.service"; + +describe("AutoConfirmNudgeService", () => { + let service: AutoConfirmNudgeService; + let autoConfirmService: MockProxy; + let fakeStateProvider: FakeStateProvider; + const userId = "user-id" as UserId; + + const mockAutoConfirmState = { + enabled: true, + showSetupDialog: false, + showBrowserNotification: true, + }; + + beforeEach(() => { + fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); + autoConfirmService = mock(); + + TestBed.configureTestingModule({ + providers: [ + AutoConfirmNudgeService, + { + provide: StateProvider, + useValue: fakeStateProvider, + }, + { + provide: AutomaticUserConfirmationService, + useValue: autoConfirmService, + }, + ], + }); + + service = TestBed.inject(AutoConfirmNudgeService); + }); + + describe("nudgeStatus$", () => { + it("should return all dismissed when user cannot manage auto-confirm", async () => { + autoConfirmService.configuration$.mockReturnValue(new BehaviorSubject(mockAutoConfirmState)); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(false)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }); + }); + + it("should return all dismissed when showBrowserNotification is false", async () => { + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: false, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }); + }); + + it("should return not dismissed when showBrowserNotification is true and user can manage", async () => { + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: true, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: false, + hasSpotlightDismissed: false, + }); + }); + + it("should return not dismissed when showBrowserNotification is undefined and user can manage", async () => { + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: undefined, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: false, + hasSpotlightDismissed: false, + }); + }); + + it("should return stored nudge status when badge is already dismissed", async () => { + await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({ + [NudgeType.AutoConfirmNudge]: { + hasBadgeDismissed: true, + hasSpotlightDismissed: false, + }, + })); + + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: true, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: true, + hasSpotlightDismissed: false, + }); + }); + + it("should return stored nudge status when spotlight is already dismissed", async () => { + await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({ + [NudgeType.AutoConfirmNudge]: { + hasBadgeDismissed: false, + hasSpotlightDismissed: true, + }, + })); + + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: true, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: false, + hasSpotlightDismissed: true, + }); + }); + + it("should return stored nudge status when both badge and spotlight are already dismissed", async () => { + await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({ + [NudgeType.AutoConfirmNudge]: { + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }, + })); + + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: true, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }); + }); + + it("should prioritize user permissions over showBrowserNotification setting", async () => { + await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({ + [NudgeType.AutoConfirmNudge]: { + hasBadgeDismissed: false, + hasSpotlightDismissed: false, + }, + })); + + autoConfirmService.configuration$.mockReturnValue( + new BehaviorSubject({ + ...mockAutoConfirmState, + showBrowserNotification: true, + }), + ); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(false)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }); + }); + + it("should respect stored dismissal even when user cannot manage auto-confirm", async () => { + await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({ + [NudgeType.AutoConfirmNudge]: { + hasBadgeDismissed: true, + hasSpotlightDismissed: false, + }, + })); + + autoConfirmService.configuration$.mockReturnValue(new BehaviorSubject(mockAutoConfirmState)); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(false)); + + const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId)); + + expect(result).toEqual({ + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }); + }); + }); +}); diff --git a/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts new file mode 100644 index 00000000000..52fc87d7604 --- /dev/null +++ b/libs/angular/src/vault/services/custom-nudges-services/auto-confirm-nudge.service.ts @@ -0,0 +1,41 @@ +import { inject, Injectable } from "@angular/core"; +import { combineLatest, map, Observable } from "rxjs"; + +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; +import { UserId } from "@bitwarden/user-core"; + +import { DefaultSingleNudgeService } from "../default-single-nudge.service"; +import { NudgeType, NudgeStatus } from "../nudges.service"; + +@Injectable({ providedIn: "root" }) +export class AutoConfirmNudgeService extends DefaultSingleNudgeService { + autoConfirmService = inject(AutomaticUserConfirmationService); + + nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { + return combineLatest([ + this.getNudgeStatus$(nudgeType, userId), + this.autoConfirmService.configuration$(userId), + this.autoConfirmService.canManageAutoConfirm$(userId), + ]).pipe( + map(([nudgeStatus, autoConfirmState, canManageAutoConfirm]) => { + if (!canManageAutoConfirm) { + return { + hasBadgeDismissed: true, + hasSpotlightDismissed: true, + }; + } + + if (nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed) { + return nudgeStatus; + } + + const dismissed = autoConfirmState.showBrowserNotification === false; + + return { + hasBadgeDismissed: dismissed, + hasSpotlightDismissed: dismissed, + }; + }), + ); + } +} diff --git a/libs/angular/src/vault/services/custom-nudges-services/index.ts b/libs/angular/src/vault/services/custom-nudges-services/index.ts index d4bfe80a525..030a46c10b2 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/index.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/index.ts @@ -1,4 +1,5 @@ export * from "./account-security-nudge.service"; +export * from "./auto-confirm-nudge.service"; export * from "./has-items-nudge.service"; export * from "./empty-vault-nudge.service"; export * from "./vault-settings-import-nudge.service"; diff --git a/libs/angular/src/vault/services/nudges.service.spec.ts b/libs/angular/src/vault/services/nudges.service.spec.ts index cba973bd894..346b22bf122 100644 --- a/libs/angular/src/vault/services/nudges.service.spec.ts +++ b/libs/angular/src/vault/services/nudges.service.spec.ts @@ -23,6 +23,7 @@ import { AccountSecurityNudgeService, VaultSettingsImportNudgeService, } from "./custom-nudges-services"; +import { AutoConfirmNudgeService } from "./custom-nudges-services/auto-confirm-nudge.service"; import { DefaultSingleNudgeService } from "./default-single-nudge.service"; import { NudgesService, NudgeType } from "./nudges.service"; @@ -35,6 +36,7 @@ describe("Vault Nudges Service", () => { EmptyVaultNudgeService, NewAccountNudgeService, AccountSecurityNudgeService, + AutoConfirmNudgeService, ]; beforeEach(async () => { @@ -73,6 +75,10 @@ describe("Vault Nudges Service", () => { provide: VaultSettingsImportNudgeService, useValue: mock(), }, + { + provide: AutoConfirmNudgeService, + useValue: mock(), + }, { provide: ApiService, useValue: mock(), diff --git a/libs/angular/src/vault/services/nudges.service.ts b/libs/angular/src/vault/services/nudges.service.ts index 19acf690d32..afd0d184d6e 100644 --- a/libs/angular/src/vault/services/nudges.service.ts +++ b/libs/angular/src/vault/services/nudges.service.ts @@ -12,6 +12,7 @@ import { NewItemNudgeService, AccountSecurityNudgeService, VaultSettingsImportNudgeService, + AutoConfirmNudgeService, NoOpNudgeService, } from "./custom-nudges-services"; import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service"; @@ -39,6 +40,7 @@ export const NudgeType = { NewNoteItemStatus: "new-note-item-status", NewSshItemStatus: "new-ssh-item-status", GeneratorNudgeStatus: "generator-nudge-status", + AutoConfirmNudge: "auto-confirm-nudge", PremiumUpgrade: "premium-upgrade", } as const; @@ -82,6 +84,7 @@ export class NudgesService { [NudgeType.NewIdentityItemStatus]: this.newItemNudgeService, [NudgeType.NewNoteItemStatus]: this.newItemNudgeService, [NudgeType.NewSshItemStatus]: this.newItemNudgeService, + [NudgeType.AutoConfirmNudge]: inject(AutoConfirmNudgeService), }; /** @@ -148,6 +151,7 @@ export class NudgesService { NudgeType.EmptyVaultNudge, NudgeType.DownloadBitwarden, NudgeType.AutofillNudge, + NudgeType.AutoConfirmNudge, ]; const nudgeTypesWithBadge$ = nudgeTypes.map((nudge) => { diff --git a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts index 9bc10e5ffc5..6777da7a9e5 100644 --- a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts +++ b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts @@ -14,8 +14,6 @@ import { PolicyType } 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 { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; @@ -45,7 +43,6 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti protected policyService: PolicyService, protected stateProvider: StateProvider, protected accountService: AccountService, - protected configService: ConfigService, protected i18nService: I18nService, ) {} @@ -116,18 +113,13 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti ), ); const orgs = await this.buildOrganizations(); - const defaulCollectionsFlagEnabled = await this.configService.getFeatureFlag( - FeatureFlag.CreateDefaultLocation, - ); let collections = organizationId == null ? storedCollections : storedCollections.filter((c) => c.organizationId === organizationId); - if (defaulCollectionsFlagEnabled) { - collections = sortDefaultCollections(collections, orgs, this.i18nService.collator); - } + collections = sortDefaultCollections(collections, orgs, this.i18nService.collator); const nestedCollections = await this.collectionService.getAllNested(collections); return new DynamicTreeNode({ diff --git a/libs/auth/src/common/types/logout-reason.type.ts b/libs/auth/src/common/types/logout-reason.type.ts index 71fff51064a..dab19ca9418 100644 --- a/libs/auth/src/common/types/logout-reason.type.ts +++ b/libs/auth/src/common/types/logout-reason.type.ts @@ -1,10 +1,10 @@ export type LogoutReason = - | "invalidGrantError" - | "vaultTimeout" - | "invalidSecurityStamp" - | "logoutNotification" - | "keyConnectorError" - | "sessionExpired" | "accessTokenUnableToBeDecrypted" + | "accountDeleted" + | "invalidAccessToken" + | "invalidSecurityStamp" + | "keyConnectorError" + | "logoutNotification" | "refreshTokenSecureStorageRetrievalFailure" - | "accountDeleted"; + | "sessionExpired" + | "vaultTimeout"; diff --git a/libs/auto-confirm/README.md b/libs/auto-confirm/README.md new file mode 100644 index 00000000000..15779018b90 --- /dev/null +++ b/libs/auto-confirm/README.md @@ -0,0 +1,18 @@ +# Automatic User Confirmation + +Owned by: admin-console + +The automatic user confirmation (auto confirm) feature enables an organization to confirm users to an organization without manual intervention +from any user as long as an administrator's device is unlocked. The feature is enabled via the following: + +1. an organization plan feature in the Bitwarden Portal (enabled by an internal team) +2. the automatic user confirmation policy in the Admin Console (enabled by an organization admin) +3. a toggle switch in the extension's admin settings page (enabled on the admin's local device) + +Once these three toggles are enabled, auto confirm will be enabled and users will be auto confirmed as long as an admin is logged in. Note that the setting in +the browser extension is not synced across clients, therefore it will not be enabled if the same admin logs into another browser until it is enabled in that +browser. This is an intentional security measure to ensure that the server cannot enable the feature unilaterally. + +Once enabled, the AutomaticUserConfirmationService runs in the background on admins' devices and reacts to push notifications from the server containing organization members who need confirmation. + +For more information about security goals and the push notification system, see [README in server repo](https://github.com/bitwarden/server/tree/main/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser). diff --git a/libs/auto-confirm/eslint.config.mjs b/libs/auto-confirm/eslint.config.mjs new file mode 100644 index 00000000000..9c37d10e3ff --- /dev/null +++ b/libs/auto-confirm/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "../../eslint.config.mjs"; + +export default [...baseConfig]; diff --git a/libs/auto-confirm/jest.config.js b/libs/auto-confirm/jest.config.js new file mode 100644 index 00000000000..461c4ef5602 --- /dev/null +++ b/libs/auto-confirm/jest.config.js @@ -0,0 +1,18 @@ +const { pathsToModuleNameMapper } = require("ts-jest"); + +const { compilerOptions } = require("../../tsconfig.base"); + +const sharedConfig = require("../../libs/shared/jest.config.angular"); + +module.exports = { + ...sharedConfig, + displayName: "auto-confirm", + setupFilesAfterEnv: ["/test.setup.ts"], + coverageDirectory: "../../coverage/libs/auto-confirm", + moduleNameMapper: pathsToModuleNameMapper( + { "@bitwarden/common/spec": ["libs/common/spec"], ...(compilerOptions?.paths ?? {}) }, + { + prefix: "/../../", + }, + ), +}; diff --git a/libs/auto-confirm/package.json b/libs/auto-confirm/package.json new file mode 100644 index 00000000000..6bb4a334d6a --- /dev/null +++ b/libs/auto-confirm/package.json @@ -0,0 +1,11 @@ +{ + "name": "@bitwarden/auto-confirm", + "version": "0.0.1", + "description": "auto confirm", + "private": true, + "type": "commonjs", + "main": "index.js", + "types": "index.d.ts", + "license": "GPL-3.0", + "author": "admin-console" +} diff --git a/libs/auto-confirm/project.json b/libs/auto-confirm/project.json new file mode 100644 index 00000000000..81efa0c77ca --- /dev/null +++ b/libs/auto-confirm/project.json @@ -0,0 +1,34 @@ +{ + "name": "auto-confirm", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/auto-confirm/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/auto-confirm", + "main": "libs/auto-confirm/src/index.ts", + "tsConfig": "libs/auto-confirm/tsconfig.lib.json", + "assets": ["libs/auto-confirm/*.md"], + "rootDir": "libs/auto-confirm/src" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/auto-confirm/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/auto-confirm/jest.config.js" + } + } + } +} diff --git a/libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts b/libs/auto-confirm/src/abstractions/auto-confirm.service.abstraction.ts similarity index 90% rename from libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts rename to libs/auto-confirm/src/abstractions/auto-confirm.service.abstraction.ts index e753184273e..9ce6cb9c1a4 100644 --- a/libs/admin-console/src/common/auto-confirm/abstractions/auto-confirm.service.abstraction.ts +++ b/libs/auto-confirm/src/abstractions/auto-confirm.service.abstraction.ts @@ -1,7 +1,6 @@ import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/user-core"; import { AutoConfirmState } from "../models/auto-confirm-state.model"; @@ -24,10 +23,7 @@ export abstract class AutomaticUserConfirmationService { * @param userId * @returns Observable an observable with a boolean telling us if the provided user may confgure the auto confirm feature. **/ - abstract canManageAutoConfirm$( - userId: UserId, - organizationId: OrganizationId, - ): Observable; + abstract canManageAutoConfirm$(userId: UserId): Observable; /** * Calls the API endpoint to initiate automatic user confirmation. * @param userId The userId of the logged in admin performing auto confirmation. This is neccesary to perform the key exchange and for permissions checks. diff --git a/libs/admin-console/src/common/auto-confirm/abstractions/index.ts b/libs/auto-confirm/src/abstractions/index.ts similarity index 100% rename from libs/admin-console/src/common/auto-confirm/abstractions/index.ts rename to libs/auto-confirm/src/abstractions/index.ts diff --git a/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.html b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.html new file mode 100644 index 00000000000..d1697c1968d --- /dev/null +++ b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.html @@ -0,0 +1,25 @@ + + + {{ "warningCapitalized" | i18n }} + + + {{ "autoConfirmWarning" | i18n }} + + {{ "autoConfirmWarningLink" | i18n }} + + + + + + + + diff --git a/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts new file mode 100644 index 00000000000..f126ce3b92c --- /dev/null +++ b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts @@ -0,0 +1,19 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./auto-confirm-warning-dialog.component.html", + imports: [ButtonModule, DialogModule, CommonModule, I18nPipe], +}) +export class AutoConfirmWarningDialogComponent { + constructor(public dialogRef: DialogRef) {} + + static open(dialogService: DialogService) { + return dialogService.open(AutoConfirmWarningDialogComponent); + } +} diff --git a/libs/auto-confirm/src/components/index.ts b/libs/auto-confirm/src/components/index.ts new file mode 100644 index 00000000000..a0310e805c6 --- /dev/null +++ b/libs/auto-confirm/src/components/index.ts @@ -0,0 +1 @@ +export * from "./auto-confirm-warning-dialog.component"; diff --git a/libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.spec.ts b/libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.spec.ts new file mode 100644 index 00000000000..aca51edb8dc --- /dev/null +++ b/libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.spec.ts @@ -0,0 +1,93 @@ +import { TestBed } from "@angular/core/testing"; +import { Router, UrlTree } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { ToastService } from "@bitwarden/components"; +import { newGuid } from "@bitwarden/guid"; + +import { AutomaticUserConfirmationService } from "../abstractions"; + +import { canAccessAutoConfirmSettings } from "./automatic-user-confirmation-settings.guard"; + +describe("canAccessAutoConfirmSettings", () => { + let accountService: MockProxy; + let autoConfirmService: MockProxy; + let toastService: MockProxy; + let i18nService: MockProxy; + let router: MockProxy; + + const mockUserId = newGuid() as UserId; + const mockAccount: Account = { + id: mockUserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + creationDate: undefined, + }; + let activeAccount$: BehaviorSubject; + + const runGuard = () => { + return TestBed.runInInjectionContext(() => { + return canAccessAutoConfirmSettings(null as any, null as any) as Observable< + boolean | UrlTree + >; + }); + }; + + beforeEach(() => { + accountService = mock(); + autoConfirmService = mock(); + toastService = mock(); + i18nService = mock(); + router = mock(); + + activeAccount$ = new BehaviorSubject(mockAccount); + accountService.activeAccount$ = activeAccount$; + + TestBed.configureTestingModule({ + providers: [ + { provide: AccountService, useValue: accountService }, + { provide: AutomaticUserConfirmationService, useValue: autoConfirmService }, + { provide: ToastService, useValue: toastService }, + { provide: I18nService, useValue: i18nService }, + { provide: Router, useValue: router }, + ], + }); + }); + + it("should allow access when user has permission", async () => { + autoConfirmService.canManageAutoConfirm$.mockReturnValue(of(true)); + + const result = await firstValueFrom(runGuard()); + + expect(result).toBe(true); + }); + + it("should redirect to vault when user lacks permission", async () => { + autoConfirmService.canManageAutoConfirm$.mockReturnValue(of(false)); + const mockUrlTree = {} as UrlTree; + router.createUrlTree.mockReturnValue(mockUrlTree); + + const result = await firstValueFrom(runGuard()); + + expect(result).toBe(mockUrlTree); + expect(router.createUrlTree).toHaveBeenCalledWith(["/tabs/vault"]); + }); + + it("should not emit when active account is null", async () => { + activeAccount$.next(null); + autoConfirmService.canManageAutoConfirm$.mockReturnValue(of(true)); + + let guardEmitted = false; + const subscription = runGuard().subscribe(() => { + guardEmitted = true; + }); + + expect(guardEmitted).toBe(false); + subscription.unsubscribe(); + }); +}); diff --git a/libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.ts b/libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.ts new file mode 100644 index 00000000000..77f01ba2801 --- /dev/null +++ b/libs/auto-confirm/src/guards/automatic-user-confirmation-settings.guard.ts @@ -0,0 +1,35 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; +import { map, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; +import { ToastService } from "@bitwarden/components"; + +import { AutomaticUserConfirmationService } from "../abstractions"; + +export const canAccessAutoConfirmSettings: CanActivateFn = () => { + const accountService = inject(AccountService); + const autoConfirmService = inject(AutomaticUserConfirmationService); + const toastService = inject(ToastService); + const i18nService = inject(I18nService); + const router = inject(Router); + + return accountService.activeAccount$.pipe( + filterOutNullish(), + switchMap((user) => autoConfirmService.canManageAutoConfirm$(user.id)), + map((canManageAutoConfirm) => { + if (!canManageAutoConfirm) { + toastService.showToast({ + variant: "error", + title: "", + message: i18nService.t("noPermissionsViewPage"), + }); + + return router.createUrlTree(["/tabs/vault"]); + } + return true; + }), + ); +}; diff --git a/libs/auto-confirm/src/guards/index.ts b/libs/auto-confirm/src/guards/index.ts new file mode 100644 index 00000000000..fa635bcb9e1 --- /dev/null +++ b/libs/auto-confirm/src/guards/index.ts @@ -0,0 +1 @@ +export * from "./automatic-user-confirmation-settings.guard"; diff --git a/libs/admin-console/src/common/auto-confirm/index.ts b/libs/auto-confirm/src/index.ts similarity index 60% rename from libs/admin-console/src/common/auto-confirm/index.ts rename to libs/auto-confirm/src/index.ts index 9187ccd39cf..56b9d0b0285 100644 --- a/libs/admin-console/src/common/auto-confirm/index.ts +++ b/libs/auto-confirm/src/index.ts @@ -1,3 +1,5 @@ export * from "./abstractions"; +export * from "./components"; +export * from "./guards"; export * from "./models"; export * from "./services"; diff --git a/libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts b/libs/auto-confirm/src/models/auto-confirm-state.model.ts similarity index 100% rename from libs/admin-console/src/common/auto-confirm/models/auto-confirm-state.model.ts rename to libs/auto-confirm/src/models/auto-confirm-state.model.ts diff --git a/libs/admin-console/src/common/auto-confirm/models/index.ts b/libs/auto-confirm/src/models/index.ts similarity index 100% rename from libs/admin-console/src/common/auto-confirm/models/index.ts rename to libs/auto-confirm/src/models/index.ts diff --git a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts b/libs/auto-confirm/src/services/default-auto-confirm.service.spec.ts similarity index 72% rename from libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts rename to libs/auto-confirm/src/services/default-auto-confirm.service.spec.ts index 133dac758b4..1d37378b96c 100644 --- a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.spec.ts +++ b/libs/auto-confirm/src/services/default-auto-confirm.service.spec.ts @@ -1,62 +1,55 @@ import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom, of, throwError } from "rxjs"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; -import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; - import { DefaultOrganizationUserService, OrganizationUserApiService, OrganizationUserConfirmRequest, -} from "../../organization-user"; +} from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { InternalOrganizationServiceAbstraction } 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 { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; +import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { newGuid } from "@bitwarden/guid"; + import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model"; import { DefaultAutomaticUserConfirmationService } from "./default-auto-confirm.service"; describe("DefaultAutomaticUserConfirmationService", () => { let service: DefaultAutomaticUserConfirmationService; - let configService: jest.Mocked; - let apiService: jest.Mocked; - let organizationUserService: jest.Mocked; + let configService: MockProxy; + let apiService: MockProxy; + let organizationUserService: MockProxy; let stateProvider: FakeStateProvider; - let organizationService: jest.Mocked; - let organizationUserApiService: jest.Mocked; + let organizationService: MockProxy; + let organizationUserApiService: MockProxy; + let policyService: MockProxy; - const mockUserId = Utils.newGuid() as UserId; - const mockConfirmingUserId = Utils.newGuid() as UserId; - const mockOrganizationId = Utils.newGuid() as OrganizationId; + const mockUserId = newGuid() as UserId; + const mockConfirmingUserId = newGuid() as UserId; + const mockOrganizationId = newGuid() as OrganizationId; let mockOrganization: Organization; beforeEach(() => { - configService = { - getFeatureFlag$: jest.fn(), - } as any; - - apiService = { - getUserPublicKey: jest.fn(), - } as any; - - organizationUserService = { - buildConfirmRequest: jest.fn(), - } as any; - + configService = mock(); + apiService = mock(); + organizationUserService = mock(); stateProvider = new FakeStateProvider(mockAccountServiceWith(mockUserId)); - - organizationService = { - organizations$: jest.fn(), - } as any; - - organizationUserApiService = { - postOrganizationUserConfirm: jest.fn(), - } as any; + organizationService = mock(); + organizationUserApiService = mock(); + policyService = mock(); TestBed.configureTestingModule({ providers: [ @@ -70,6 +63,7 @@ describe("DefaultAutomaticUserConfirmationService", () => { useValue: organizationService, }, { provide: OrganizationUserApiService, useValue: organizationUserApiService }, + { provide: PolicyService, useValue: policyService }, ], }); @@ -80,9 +74,13 @@ describe("DefaultAutomaticUserConfirmationService", () => { stateProvider, organizationService, organizationUserApiService, + policyService, ); - const mockOrgData = new OrganizationData({} as any, {} as any); + const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, { + isMember: true, + isProviderUser: false, + }); mockOrgData.id = mockOrganizationId; mockOrgData.useAutomaticUserConfirmation = true; @@ -180,7 +178,7 @@ describe("DefaultAutomaticUserConfirmationService", () => { }); it("should preserve other user configurations when updating", async () => { - const otherUserId = Utils.newGuid() as UserId; + const otherUserId = newGuid() as UserId; const otherConfig = new AutoConfirmState(); otherConfig.enabled = true; @@ -209,12 +207,13 @@ describe("DefaultAutomaticUserConfirmationService", () => { beforeEach(() => { const organizations$ = new BehaviorSubject([mockOrganization]); organizationService.organizations$.mockReturnValue(organizations$); + policyService.policyAppliesToUser$.mockReturnValue(of(true)); }); it("should return true when feature flag is enabled and organization allows management", async () => { configService.getFeatureFlag$.mockReturnValue(of(true)); - const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage$ = service.canManageAutoConfirm$(mockUserId); const canManage = await firstValueFrom(canManage$); expect(canManage).toBe(true); @@ -223,7 +222,7 @@ describe("DefaultAutomaticUserConfirmationService", () => { it("should return false when feature flag is disabled", async () => { configService.getFeatureFlag$.mockReturnValue(of(false)); - const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage$ = service.canManageAutoConfirm$(mockUserId); const canManage = await firstValueFrom(canManage$); expect(canManage).toBe(false); @@ -233,7 +232,10 @@ describe("DefaultAutomaticUserConfirmationService", () => { configService.getFeatureFlag$.mockReturnValue(of(true)); // Create organization without manageUsers permission - const mockOrgData = new OrganizationData({} as any, {} as any); + const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, { + isMember: true, + isProviderUser: false, + }); mockOrgData.id = mockOrganizationId; mockOrgData.useAutomaticUserConfirmation = true; const permissions = new PermissionsApi(); @@ -244,7 +246,7 @@ describe("DefaultAutomaticUserConfirmationService", () => { const organizations$ = new BehaviorSubject([orgWithoutManageUsers]); organizationService.organizations$.mockReturnValue(organizations$); - const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage$ = service.canManageAutoConfirm$(mockUserId); const canManage = await firstValueFrom(canManage$); expect(canManage).toBe(false); @@ -254,7 +256,10 @@ describe("DefaultAutomaticUserConfirmationService", () => { configService.getFeatureFlag$.mockReturnValue(of(true)); // Create organization without useAutomaticUserConfirmation - const mockOrgData = new OrganizationData({} as any, {} as any); + const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, { + isMember: true, + isProviderUser: false, + }); mockOrgData.id = mockOrganizationId; mockOrgData.useAutomaticUserConfirmation = false; const permissions = new PermissionsApi(); @@ -265,7 +270,7 @@ describe("DefaultAutomaticUserConfirmationService", () => { const organizations$ = new BehaviorSubject([orgWithoutAutoConfirm]); organizationService.organizations$.mockReturnValue(organizations$); - const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage$ = service.canManageAutoConfirm$(mockUserId); const canManage = await firstValueFrom(canManage$); expect(canManage).toBe(false); @@ -277,7 +282,31 @@ describe("DefaultAutomaticUserConfirmationService", () => { const organizations$ = new BehaviorSubject([]); organizationService.organizations$.mockReturnValue(organizations$); - const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage$ = service.canManageAutoConfirm$(mockUserId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return false when the user is not a member of any organizations", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + + // Create organization where user is not a member + const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, { + isMember: false, + isProviderUser: false, + }); + mockOrgData.id = mockOrganizationId; + mockOrgData.useAutomaticUserConfirmation = true; + const permissions = new PermissionsApi(); + permissions.manageUsers = true; + mockOrgData.permissions = permissions; + const orgWhereNotMember = new Organization(mockOrgData); + + const organizations$ = new BehaviorSubject([orgWhereNotMember]); + organizationService.organizations$.mockReturnValue(organizations$); + + const canManage$ = service.canManageAutoConfirm$(mockUserId); const canManage = await firstValueFrom(canManage$); expect(canManage).toBe(false); @@ -286,11 +315,58 @@ describe("DefaultAutomaticUserConfirmationService", () => { it("should use the correct feature flag", async () => { configService.getFeatureFlag$.mockReturnValue(of(true)); - const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId); + const canManage$ = service.canManageAutoConfirm$(mockUserId); await firstValueFrom(canManage$); expect(configService.getFeatureFlag$).toHaveBeenCalledWith(FeatureFlag.AutoConfirm); }); + + it("should return false when policy does not apply to user", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policyAppliesToUser$.mockReturnValue(of(false)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + }); + + it("should return true when policy applies to user", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policyAppliesToUser$.mockReturnValue(of(true)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(true); + }); + + it("should check policy with correct PolicyType and userId", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policyAppliesToUser$.mockReturnValue(of(true)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId); + await firstValueFrom(canManage$); + + expect(policyService.policyAppliesToUser$).toHaveBeenCalledWith( + PolicyType.AutoConfirm, + mockUserId, + ); + }); + + it("should return false when feature flag is enabled but policy does not apply", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policyAppliesToUser$.mockReturnValue(of(false)); + + const canManage$ = service.canManageAutoConfirm$(mockUserId); + const canManage = await firstValueFrom(canManage$); + + expect(canManage).toBe(false); + expect(policyService.policyAppliesToUser$).toHaveBeenCalledWith( + PolicyType.AutoConfirm, + mockUserId, + ); + }); }); describe("autoConfirmUser", () => { @@ -305,8 +381,11 @@ describe("DefaultAutomaticUserConfirmationService", () => { const organizations$ = new BehaviorSubject([mockOrganization]); organizationService.organizations$.mockReturnValue(organizations$); configService.getFeatureFlag$.mockReturnValue(of(true)); + policyService.policyAppliesToUser$.mockReturnValue(of(true)); - apiService.getUserPublicKey.mockResolvedValue({ publicKey: mockPublicKey } as any); + apiService.getUserPublicKey.mockResolvedValue({ + publicKey: mockPublicKey, + } as UserKeyResponse); jest.spyOn(Utils, "fromB64ToArray").mockReturnValue(mockPublicKeyArray); organizationUserService.buildConfirmRequest.mockReturnValue(of(mockConfirmRequest)); organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined); diff --git a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts b/libs/auto-confirm/src/services/default-auto-confirm.service.ts similarity index 75% rename from libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts rename to libs/auto-confirm/src/services/default-auto-confirm.service.ts index d6c435b84a3..109ccb6c9db 100644 --- a/libs/admin-console/src/common/auto-confirm/services/default-auto-confirm.service.ts +++ b/libs/auto-confirm/src/services/default-auto-confirm.service.ts @@ -1,17 +1,20 @@ import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; +import { + OrganizationUserApiService, + OrganizationUserService, +} from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { InternalOrganizationServiceAbstraction } 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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { getById } from "@bitwarden/common/platform/misc"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { OrganizationId } from "@bitwarden/common/types/guid"; import { StateProvider } from "@bitwarden/state"; import { UserId } from "@bitwarden/user-core"; -import { OrganizationUserApiService, OrganizationUserService } from "../../organization-user"; import { AutomaticUserConfirmationService } from "../abstractions/auto-confirm.service.abstraction"; import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model"; @@ -23,6 +26,7 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon private stateProvider: StateProvider, private organizationService: InternalOrganizationServiceAbstraction, private organizationUserApiService: OrganizationUserApiService, + private policyService: PolicyService, ) {} private autoConfirmState(userId: UserId) { return this.stateProvider.getUser(userId, AUTO_CONFIRM_STATE); @@ -43,15 +47,19 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon }); } - canManageAutoConfirm$(userId: UserId, organizationId: OrganizationId): Observable { + canManageAutoConfirm$(userId: UserId): Observable { return combineLatest([ this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm), - this.organizationService.organizations$(userId).pipe(getById(organizationId)), + this.organizationService + .organizations$(userId) + // auto-confirm does not allow the user to be part of any other organization (even if admin or owner) + // so we can assume that the first organization is the relevant one. + .pipe(map((organizations) => organizations[0])), + this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId), ]).pipe( map( - ([enabled, organization]) => - (enabled && organization?.canManageUsers && organization?.useAutomaticUserConfirmation) ?? - false, + ([enabled, organization, policyEnabled]) => + enabled && policyEnabled && (organization?.canManageAutoConfirm ?? false), ), ); } @@ -62,7 +70,7 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon organization: Organization, ): Promise { await firstValueFrom( - this.canManageAutoConfirm$(userId, organization.id).pipe( + this.canManageAutoConfirm$(userId).pipe( map((canManage) => { if (!canManage) { throw new Error("Cannot automatically confirm user (insufficient permissions)"); diff --git a/libs/admin-console/src/common/auto-confirm/services/index.ts b/libs/auto-confirm/src/services/index.ts similarity index 100% rename from libs/admin-console/src/common/auto-confirm/services/index.ts rename to libs/auto-confirm/src/services/index.ts diff --git a/libs/auto-confirm/test.setup.ts b/libs/auto-confirm/test.setup.ts new file mode 100644 index 00000000000..5c248668a6d --- /dev/null +++ b/libs/auto-confirm/test.setup.ts @@ -0,0 +1,23 @@ +import "@bitwarden/ui-common/setup-jest"; + +Object.defineProperty(window, "CSS", { value: null }); +Object.defineProperty(window, "getComputedStyle", { + value: () => { + return { + display: "none", + appearance: ["-webkit-appearance"], + }; + }, +}); + +Object.defineProperty(document, "doctype", { + value: "", +}); +Object.defineProperty(document.body.style, "transform", { + value: () => { + return { + enumerable: true, + configurable: true, + }; + }, +}); diff --git a/libs/auto-confirm/tsconfig.eslint.json b/libs/auto-confirm/tsconfig.eslint.json new file mode 100644 index 00000000000..3daf120441a --- /dev/null +++ b/libs/auto-confirm/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": ["src/**/*.ts", "src/**/*.js"], + "exclude": ["**/build", "**/dist"] +} diff --git a/libs/auto-confirm/tsconfig.json b/libs/auto-confirm/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/auto-confirm/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/auto-confirm/tsconfig.lib.json b/libs/auto-confirm/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/auto-confirm/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/auto-confirm/tsconfig.spec.json b/libs/auto-confirm/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/auto-confirm/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 72a17f0fa87..7e4ff031ef2 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -446,6 +446,13 @@ export abstract class ApiService { abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise; abstract postSetupPayment(): Promise; + /** + * Retrieves the bearer access token for the user. + * If the access token is expired or within 5 minutes of expiration, attempts to refresh the token + * and persists the refresh token to state before returning it. + * @param userId The user for whom we're retrieving the access token + * @returns The access token, or an Error if no access token exists. + */ abstract getActiveBearerToken(userId: UserId): Promise; abstract fetch(request: Request): Promise; abstract nativeFetch(request: Request): Promise; diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 54d2f93ac03..d1181343549 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -75,8 +75,8 @@ export function canAccessEmergencyAccess( ) { return combineLatest([ configService.getFeatureFlag$(FeatureFlag.AutoConfirm), - policyService.policiesByType$(PolicyType.AutoConfirm, userId), - ]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled))); + policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId), + ]).pipe(map(([enabled, policyAppliesToUser]) => !(enabled && policyAppliesToUser))); } /** diff --git a/libs/common/src/admin-console/models/data/organization.data.spec.ts b/libs/common/src/admin-console/models/data/organization.data.spec.ts index 4b74e03db8d..ef8db3b01be 100644 --- a/libs/common/src/admin-console/models/data/organization.data.spec.ts +++ b/libs/common/src/admin-console/models/data/organization.data.spec.ts @@ -64,6 +64,7 @@ describe("ORGANIZATIONS state", () => { isAdminInitiated: false, ssoEnabled: false, ssoMemberDecryptionType: undefined, + useDisableSMAdsForUsers: false, usePhishingBlocker: false, }, }; diff --git a/libs/common/src/admin-console/models/data/organization.data.ts b/libs/common/src/admin-console/models/data/organization.data.ts index de0d21fbf17..de3f6af4448 100644 --- a/libs/common/src/admin-console/models/data/organization.data.ts +++ b/libs/common/src/admin-console/models/data/organization.data.ts @@ -64,6 +64,7 @@ export class OrganizationData { userIsManagedByOrganization: boolean; useAccessIntelligence: boolean; useAdminSponsoredFamilies: boolean; + useDisableSMAdsForUsers: boolean; isAdminInitiated: boolean; ssoEnabled: boolean; ssoMemberDecryptionType?: MemberDecryptionType; @@ -133,6 +134,7 @@ export class OrganizationData { this.userIsManagedByOrganization = response.userIsManagedByOrganization; this.useAccessIntelligence = response.useAccessIntelligence; this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies; + this.useDisableSMAdsForUsers = response.useDisableSMAdsForUsers ?? false; this.isAdminInitiated = response.isAdminInitiated; this.ssoEnabled = response.ssoEnabled; this.ssoMemberDecryptionType = response.ssoMemberDecryptionType; diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index dba4a1fedb3..2991ffb7caa 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -95,6 +95,7 @@ export class Organization { userIsManagedByOrganization: boolean; useAccessIntelligence: boolean; useAdminSponsoredFamilies: boolean; + useDisableSMAdsForUsers: boolean; isAdminInitiated: boolean; ssoEnabled: boolean; ssoMemberDecryptionType?: MemberDecryptionType; @@ -160,6 +161,7 @@ export class Organization { this.userIsManagedByOrganization = obj.userIsManagedByOrganization; this.useAccessIntelligence = obj.useAccessIntelligence; this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies; + this.useDisableSMAdsForUsers = obj.useDisableSMAdsForUsers ?? false; this.isAdminInitiated = obj.isAdminInitiated; this.ssoEnabled = obj.ssoEnabled; this.ssoMemberDecryptionType = obj.ssoMemberDecryptionType; @@ -381,6 +383,13 @@ export class Organization { return this.familySponsorshipAvailable || this.familySponsorshipFriendlyName !== null; } + /** + * Do not call this function to perform business logic, use the function in @link AutomaticUserConfirmationService instead. + **/ + get canManageAutoConfirm() { + return this.isMember && this.canManageUsers && this.useAutomaticUserConfirmation; + } + static fromJSON(json: Jsonify) { if (json == null) { return null; diff --git a/libs/common/src/admin-console/models/response/organization.response.ts b/libs/common/src/admin-console/models/response/organization.response.ts index cf3ae6a90f7..c2f7dd6b6a8 100644 --- a/libs/common/src/admin-console/models/response/organization.response.ts +++ b/libs/common/src/admin-console/models/response/organization.response.ts @@ -38,6 +38,7 @@ export class OrganizationResponse extends BaseResponse { limitCollectionDeletion: boolean; limitItemDeletion: boolean; allowAdminAccessToAllCollectionItems: boolean; + useDisableSMAdsForUsers: boolean; useAccessIntelligence: boolean; usePhishingBlocker: boolean; @@ -81,6 +82,7 @@ export class OrganizationResponse extends BaseResponse { this.allowAdminAccessToAllCollectionItems = this.getResponseProperty( "AllowAdminAccessToAllCollectionItems", ); + this.useDisableSMAdsForUsers = this.getResponseProperty("UseDisableSMAdsForUsers") ?? false; // Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence) this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights"); this.usePhishingBlocker = this.getResponseProperty("UsePhishingBlocker") ?? false; diff --git a/libs/common/src/admin-console/models/response/profile-organization.response.ts b/libs/common/src/admin-console/models/response/profile-organization.response.ts index 263e8e7d6b9..44ba7555a7e 100644 --- a/libs/common/src/admin-console/models/response/profile-organization.response.ts +++ b/libs/common/src/admin-console/models/response/profile-organization.response.ts @@ -59,6 +59,7 @@ export class ProfileOrganizationResponse extends BaseResponse { userIsManagedByOrganization: boolean; useAccessIntelligence: boolean; useAdminSponsoredFamilies: boolean; + useDisableSMAdsForUsers: boolean; isAdminInitiated: boolean; ssoEnabled: boolean; ssoMemberDecryptionType?: MemberDecryptionType; @@ -133,6 +134,7 @@ export class ProfileOrganizationResponse extends BaseResponse { // Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence) this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights"); this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies"); + this.useDisableSMAdsForUsers = this.getResponseProperty("UseDisableSMAdsForUsers") ?? false; this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated"); this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false; this.ssoMemberDecryptionType = this.getResponseProperty("SsoMemberDecryptionType"); diff --git a/libs/common/src/auth/abstractions/auth-request-answering/README.md b/libs/common/src/auth/abstractions/auth-request-answering/README.md index 9a24f095d70..edc368fd91b 100644 --- a/libs/common/src/auth/abstractions/auth-request-answering/README.md +++ b/libs/common/src/auth/abstractions/auth-request-answering/README.md @@ -1,7 +1,6 @@ # Auth Request Answering Service -This feature is to allow for the taking of auth requests that are received via websockets by the background service to -be acted on when the user loads up a client. Currently only implemented with the browser client. +This feature is to allow for the taking of auth requests that are received via websockets to be acted on when the user loads up a client. See diagram for the high level picture of how this is wired up. diff --git a/libs/common/src/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction.ts b/libs/common/src/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction.ts index f45cb34496e..7edae163951 100644 --- a/libs/common/src/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction.ts @@ -1,30 +1,50 @@ +import { Observable } from "rxjs"; + import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service"; import { UserId } from "@bitwarden/user-core"; -export abstract class AuthRequestAnsweringServiceAbstraction { +export abstract class AuthRequestAnsweringService { /** * Tries to either display the dialog for the user or will preserve its data and show it at a * later time. Even in the event the dialog is shown immediately, this will write to global state * so that even if someone closes a window or a popup and comes back, it could be processed later. * Only way to clear out the global state is to respond to the auth request. + * - Implemented on Extension and Desktop. * - * Currently, this is only implemented for browser extension. - * - * @param userId The UserId that the auth request is for. + * @param authRequestUserId The UserId that the auth request is for. * @param authRequestId The id of the auth request that is to be processed. */ - abstract receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise; + abstract receivedPendingAuthRequest?( + authRequestUserId: UserId, + authRequestId: string, + ): Promise; /** - * When a system notification is clicked, this function is used to process that event. + * Confirms whether or not the user meets the conditions required to show them an + * approval dialog immediately. + * + * @param authRequestUserId the UserId that the auth request is for. + * @returns boolean stating whether or not the user meets conditions + */ + abstract activeUserMeetsConditionsToShowApprovalDialog( + authRequestUserId: UserId, + ): Promise; + + /** + * Sets up listeners for scenarios where the user unlocks and we want to process + * any pending auth requests in state. + * + * @param destroy$ The destroy$ observable from the caller + */ + abstract setupUnlockListenersForProcessingAuthRequests(destroy$: Observable): void; + + /** + * When a system notification is clicked, this method is used to process that event. + * - Implemented on Extension only. + * - Desktop does not implement this method because click handling is already setup in + * electron-main-messaging.service.ts. * * @param event The event passed in. Check initNotificationSubscriptions in main.background.ts. */ abstract handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise; - - /** - * Process notifications that have been received but didn't meet the conditions to display the - * approval dialog. - */ - abstract processPendingAuthRequests(): Promise; } diff --git a/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts b/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts deleted file mode 100644 index a44dde04f5f..00000000000 --- a/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { mock, MockProxy } from "jest-mock-extended"; -import { of } from "rxjs"; - -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags"; -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ActionsService } from "@bitwarden/common/platform/actions"; -import { - ButtonLocation, - SystemNotificationEvent, - SystemNotificationsService, -} from "@bitwarden/common/platform/system-notifications/system-notifications.service"; -import { mockAccountInfoWith } from "@bitwarden/common/spec"; -import { UserId } from "@bitwarden/user-core"; - -import { AuthRequestAnsweringService } from "./auth-request-answering.service"; -import { PendingAuthRequestsStateService } from "./pending-auth-requests.state"; - -describe("AuthRequestAnsweringService", () => { - let accountService: MockProxy; - let actionService: MockProxy; - let authService: MockProxy; - let i18nService: MockProxy; - let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$ - let messagingService: MockProxy; - let pendingAuthRequestsState: MockProxy; - let platformUtilsService: MockProxy; - let systemNotificationsService: MockProxy; - - let sut: AuthRequestAnsweringService; - - const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId; - - beforeEach(() => { - accountService = mock(); - actionService = mock(); - authService = mock(); - i18nService = mock(); - masterPasswordService = { forceSetPasswordReason$: jest.fn() }; - messagingService = mock(); - pendingAuthRequestsState = mock(); - platformUtilsService = mock(); - systemNotificationsService = mock(); - - // Common defaults - authService.activeAccountStatus$ = of(AuthenticationStatus.Locked); - const accountInfo = mockAccountInfoWith({ - email: "user@example.com", - name: "User", - }); - accountService.activeAccount$ = of({ - id: userId, - ...accountInfo, - }); - accountService.accounts$ = of({ - [userId]: accountInfo, - }); - (masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue( - of(ForceSetPasswordReason.None), - ); - platformUtilsService.isPopupOpen.mockResolvedValue(false); - i18nService.t.mockImplementation( - (key: string, p1?: any) => `${key}${p1 != null ? ":" + p1 : ""}`, - ); - systemNotificationsService.create.mockResolvedValue("notif-id"); - - sut = new AuthRequestAnsweringService( - accountService, - actionService, - authService, - i18nService, - masterPasswordService, - messagingService, - pendingAuthRequestsState, - platformUtilsService, - systemNotificationsService, - ); - }); - - describe("handleAuthRequestNotificationClicked", () => { - it("clears notification and opens popup when notification body is clicked", async () => { - const event: SystemNotificationEvent = { - id: "123", - buttonIdentifier: ButtonLocation.NotificationButton, - }; - - await sut.handleAuthRequestNotificationClicked(event); - - expect(systemNotificationsService.clear).toHaveBeenCalledWith({ id: "123" }); - expect(actionService.openPopup).toHaveBeenCalledTimes(1); - }); - - it("does nothing when an optional button is clicked", async () => { - const event: SystemNotificationEvent = { - id: "123", - buttonIdentifier: ButtonLocation.FirstOptionalButton, - }; - - await sut.handleAuthRequestNotificationClicked(event); - - expect(systemNotificationsService.clear).not.toHaveBeenCalled(); - expect(actionService.openPopup).not.toHaveBeenCalled(); - }); - }); - - describe("receivedPendingAuthRequest", () => { - const authRequestId = "req-abc"; - - it("creates a system notification when popup is not open", async () => { - platformUtilsService.isPopupOpen.mockResolvedValue(false); - authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); - - await sut.receivedPendingAuthRequest(userId, authRequestId); - - expect(i18nService.t).toHaveBeenCalledWith("accountAccessRequested"); - expect(i18nService.t).toHaveBeenCalledWith("confirmAccessAttempt", "user@example.com"); - expect(systemNotificationsService.create).toHaveBeenCalledWith({ - id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, - title: "accountAccessRequested", - body: "confirmAccessAttempt:user@example.com", - buttons: [], - }); - }); - - it("does not create a notification when popup is open, user is active, unlocked, and no force set password", async () => { - platformUtilsService.isPopupOpen.mockResolvedValue(true); - authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); - (masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue( - of(ForceSetPasswordReason.None), - ); - - await sut.receivedPendingAuthRequest(userId, authRequestId); - - expect(systemNotificationsService.create).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.ts b/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.ts deleted file mode 100644 index 834d6ac7bcc..00000000000 --- a/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { firstValueFrom } from "rxjs"; - -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags"; -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service"; -import { MasterPasswordServiceAbstraction } 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ActionsService } from "@bitwarden/common/platform/actions"; -import { - ButtonLocation, - SystemNotificationEvent, - SystemNotificationsService, -} from "@bitwarden/common/platform/system-notifications/system-notifications.service"; -import { UserId } from "@bitwarden/user-core"; - -import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction"; - -import { - PendingAuthRequestsStateService, - PendingAuthUserMarker, -} from "./pending-auth-requests.state"; - -export class AuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction { - constructor( - private readonly accountService: AccountService, - private readonly actionService: ActionsService, - private readonly authService: AuthService, - private readonly i18nService: I18nService, - private readonly masterPasswordService: MasterPasswordServiceAbstraction, - private readonly messagingService: MessagingService, - private readonly pendingAuthRequestsState: PendingAuthRequestsStateService, - private readonly platformUtilsService: PlatformUtilsService, - private readonly systemNotificationsService: SystemNotificationsService, - ) {} - - async receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise { - const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); - const activeUserId: UserId | null = await firstValueFrom( - this.accountService.activeAccount$.pipe(getOptionalUserId), - ); - const forceSetPasswordReason = await firstValueFrom( - this.masterPasswordService.forceSetPasswordReason$(userId), - ); - const popupOpen = await this.platformUtilsService.isPopupOpen(); - - // Always persist the pending marker for this user to global state. - await this.pendingAuthRequestsState.add(userId); - - // These are the conditions we are looking for to know if the extension is in a state to show - // the approval dialog. - const userIsAvailableToReceiveAuthRequest = - popupOpen && - authStatus === AuthenticationStatus.Unlocked && - activeUserId === userId && - forceSetPasswordReason === ForceSetPasswordReason.None; - - if (!userIsAvailableToReceiveAuthRequest) { - // Get the user's email to include in the system notification - const accounts = await firstValueFrom(this.accountService.accounts$); - const emailForUser = accounts[userId].email; - - await this.systemNotificationsService.create({ - id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, // the underscore is an important delimiter. - title: this.i18nService.t("accountAccessRequested"), - body: this.i18nService.t("confirmAccessAttempt", emailForUser), - buttons: [], - }); - return; - } - - // Popup is open and conditions are met; open dialog immediately for this request - this.messagingService.send("openLoginApproval"); - } - - async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise { - if (event.buttonIdentifier === ButtonLocation.NotificationButton) { - await this.systemNotificationsService.clear({ - id: `${event.id}`, - }); - await this.actionService.openPopup(); - } - } - - async processPendingAuthRequests(): Promise { - // Prune any stale pending requests (older than 15 minutes) - // This comes from GlobalSettings.cs - // public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15); - const fifteenMinutesMs = 15 * 60 * 1000; - - await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs); - - const pendingAuthRequestsInState: PendingAuthUserMarker[] = - (await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? []; - - if (pendingAuthRequestsInState.length > 0) { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some( - (e) => e.userId === activeUserId, - ); - - if (pendingAuthRequestsForActiveUser) { - this.messagingService.send("openLoginApproval"); - } - } - } -} diff --git a/libs/common/src/auth/services/auth-request-answering/default-auth-request-answering.service.spec.ts b/libs/common/src/auth/services/auth-request-answering/default-auth-request-answering.service.spec.ts new file mode 100644 index 00000000000..624133d1e31 --- /dev/null +++ b/libs/common/src/auth/services/auth-request-answering/default-auth-request-answering.service.spec.ts @@ -0,0 +1,444 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of, Subject } from "rxjs"; + +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { + ButtonLocation, + SystemNotificationEvent, +} from "@bitwarden/common/platform/system-notifications/system-notifications.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/user-core"; + +import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction"; + +import { DefaultAuthRequestAnsweringService } from "./default-auth-request-answering.service"; +import { + PendingAuthRequestsStateService, + PendingAuthUserMarker, +} from "./pending-auth-requests.state"; + +describe("DefaultAuthRequestAnsweringService", () => { + let accountService: MockProxy; + let authService: MockProxy; + let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$ + let messagingService: MockProxy; + let pendingAuthRequestsState: MockProxy; + + let sut: AuthRequestAnsweringService; + + const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId; + const userAccountInfo = mockAccountInfoWith({ + name: "User", + email: "user@example.com", + }); + const userAccount: Account = { + id: userId, + ...userAccountInfo, + }; + + const otherUserId = "554c3112-9a75-23af-ab80-8dk3e9bl5i8e" as UserId; + const otherUserAccountInfo = mockAccountInfoWith({ + name: "Other", + email: "other@example.com", + }); + const otherUserAccount: Account = { + id: otherUserId, + ...otherUserAccountInfo, + }; + + beforeEach(() => { + accountService = mock(); + authService = mock(); + masterPasswordService = { + forceSetPasswordReason$: jest.fn().mockReturnValue(of(ForceSetPasswordReason.None)), + }; + messagingService = mock(); + pendingAuthRequestsState = mock(); + + // Common defaults + authService.activeAccountStatus$ = of(AuthenticationStatus.Locked); + accountService.activeAccount$ = of(userAccount); + accountService.accounts$ = of({ + [userId]: userAccountInfo, + [otherUserId]: otherUserAccountInfo, + }); + + sut = new DefaultAuthRequestAnsweringService( + accountService, + authService, + masterPasswordService, + messagingService, + pendingAuthRequestsState, + ); + }); + + describe("activeUserMeetsConditionsToShowApprovalDialog()", () => { + it("should return false if there is no active user", async () => { + // Arrange + accountService.activeAccount$ = of(null); + + // Act + const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId); + + // Assert + expect(result).toBe(false); + }); + + it("should return false if the active user is not the intended recipient of the auth request", async () => { + // Arrange + authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); + + // Act + const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(otherUserId); + + // Assert + expect(result).toBe(false); + }); + + it("should return false if the active user is not unlocked", async () => { + // Arrange + authService.activeAccountStatus$ = of(AuthenticationStatus.Locked); + + // Act + const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId); + + // Assert + expect(result).toBe(false); + }); + + it("should return false if the active user is required to set/change their master password", async () => { + // Arrange + masterPasswordService.forceSetPasswordReason$.mockReturnValue( + of(ForceSetPasswordReason.WeakMasterPassword), + ); + + // Act + const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId); + + // Assert + expect(result).toBe(false); + }); + + it("should return true if the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", async () => { + // Arrange + authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); + + // Act + const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId); + + // Assert + expect(result).toBe(true); + }); + }); + + describe("setupUnlockListenersForProcessingAuthRequests()", () => { + let destroy$: Subject; + let activeAccount$: BehaviorSubject; + let activeAccountStatus$: BehaviorSubject; + let authStatusForSubjects: Map>; + let pendingRequestMarkers: PendingAuthUserMarker[]; + + beforeEach(() => { + destroy$ = new Subject(); + activeAccount$ = new BehaviorSubject(userAccount); + activeAccountStatus$ = new BehaviorSubject(AuthenticationStatus.Locked); + authStatusForSubjects = new Map(); + pendingRequestMarkers = []; + + accountService.activeAccount$ = activeAccount$; + authService.activeAccountStatus$ = activeAccountStatus$; + authService.authStatusFor$.mockImplementation((id: UserId) => { + if (!authStatusForSubjects.has(id)) { + authStatusForSubjects.set(id, new BehaviorSubject(AuthenticationStatus.Locked)); + } + return authStatusForSubjects.get(id)!; + }); + + pendingAuthRequestsState.getAll$.mockReturnValue(of([])); + }); + + afterEach(() => { + destroy$.next(); + destroy$.complete(); + }); + + describe("active account switching", () => { + it("should process pending auth requests when switching to an unlocked user", async () => { + // Arrange + authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked)); + pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + + // Simulate account switching to an Unlocked account + activeAccount$.next(otherUserAccount); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); // Allows observable chain to complete before assertion + expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval"); + }); + + it("should NOT process pending auth requests when switching to a locked user", async () => { + // Arrange + authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Locked)); + pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccount$.next(otherUserAccount); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + + it("should NOT process pending auth requests when switching to a logged out user", async () => { + // Arrange + authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.LoggedOut)); + pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccount$.next(otherUserAccount); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + + it("should NOT process pending auth requests when active account becomes null", async () => { + // Arrange + pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccount$.next(null); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + + it("should handle multiple user switches correctly", async () => { + // Arrange + authStatusForSubjects.set(userId, new BehaviorSubject(AuthenticationStatus.Locked)); + authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked)); + pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + + // Switch to unlocked user (should trigger) + activeAccount$.next(otherUserAccount); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Switch to locked user (should NOT trigger) + activeAccount$.next(userAccount); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Assert + expect(messagingService.send).toHaveBeenCalledTimes(1); + }); + + it("should NOT process pending auth requests when switching to an Unlocked user who is required to set/change their master password", async () => { + // Arrange + masterPasswordService.forceSetPasswordReason$.mockReturnValue( + of(ForceSetPasswordReason.WeakMasterPassword), + ); + authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked)); + pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccount$.next(otherUserAccount); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + }); + + describe("authentication status transitions", () => { + it("should process pending auth requests when active account transitions to Unlocked", async () => { + // Arrange + activeAccountStatus$.next(AuthenticationStatus.Locked); + pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval"); + }); + + it("should process pending auth requests when transitioning from LoggedOut to Unlocked", async () => { + // Arrange + activeAccountStatus$.next(AuthenticationStatus.LoggedOut); + pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval"); + }); + + it("should NOT process pending auth requests when transitioning from Unlocked to Locked", async () => { + // Arrange + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Clear any calls from the initial trigger (from null -> Unlocked) + messagingService.send.mockClear(); + + activeAccountStatus$.next(AuthenticationStatus.Locked); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + + it("should NOT process pending auth requests when transitioning from Locked to LoggedOut", async () => { + // Arrange + activeAccountStatus$.next(AuthenticationStatus.Locked); + pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccountStatus$.next(AuthenticationStatus.LoggedOut); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + + it("should NOT process pending auth requests when staying in Unlocked status", async () => { + // Arrange + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Clear any calls from the initial trigger (from null -> Unlocked) + messagingService.send.mockClear(); + + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + + it("should handle multiple status transitions correctly", async () => { + // Arrange + activeAccountStatus$.next(AuthenticationStatus.Locked); + pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + + // Transition to Unlocked (should trigger) + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Transition to Locked (should NOT trigger) + activeAccountStatus$.next(AuthenticationStatus.Locked); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Transition back to Unlocked (should trigger again) + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Assert + expect(messagingService.send).toHaveBeenCalledTimes(2); + expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval"); + }); + + it("should NOT process pending auth requests when active account transitions to Unlocked but is required to set/change their master password", async () => { + // Arrange + masterPasswordService.forceSetPasswordReason$.mockReturnValue( + of(ForceSetPasswordReason.WeakMasterPassword), + ); + activeAccountStatus$.next(AuthenticationStatus.Locked); + pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + }); + + describe("subscription cleanup", () => { + it("should stop processing when destroy$ emits", async () => { + // Arrange + authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked)); + pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }]; + pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + + // Emit destroy signal + destroy$.next(); + + // Try to trigger processing after cleanup + activeAccount$.next(otherUserAccount); + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + }); + }); + + describe("handleAuthRequestNotificationClicked()", () => { + it("should throw an error", async () => { + // Arrange + const event: SystemNotificationEvent = { + id: "123", + buttonIdentifier: ButtonLocation.NotificationButton, + }; + + // Act + const promise = sut.handleAuthRequestNotificationClicked(event); + + // Assert + await expect(promise).rejects.toThrow( + "handleAuthRequestNotificationClicked() not implemented for this client", + ); + }); + }); +}); diff --git a/libs/common/src/auth/services/auth-request-answering/default-auth-request-answering.service.ts b/libs/common/src/auth/services/auth-request-answering/default-auth-request-answering.service.ts new file mode 100644 index 00000000000..ea8067ed351 --- /dev/null +++ b/libs/common/src/auth/services/auth-request-answering/default-auth-request-answering.service.ts @@ -0,0 +1,140 @@ +import { + distinctUntilChanged, + filter, + firstValueFrom, + map, + Observable, + pairwise, + startWith, + switchMap, + take, + takeUntil, + tap, +} from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service"; +import { UserId } from "@bitwarden/user-core"; + +import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction"; + +import { + PendingAuthRequestsStateService, + PendingAuthUserMarker, +} from "./pending-auth-requests.state"; + +export class DefaultAuthRequestAnsweringService implements AuthRequestAnsweringService { + constructor( + protected readonly accountService: AccountService, + protected readonly authService: AuthService, + protected readonly masterPasswordService: MasterPasswordServiceAbstraction, + protected readonly messagingService: MessagingService, + protected readonly pendingAuthRequestsState: PendingAuthRequestsStateService, + ) {} + + async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise { + // If the active user is not the intended recipient of the auth request, return false + const activeUserId: UserId | null = await firstValueFrom( + this.accountService.activeAccount$.pipe(getOptionalUserId), + ); + if (activeUserId !== authRequestUserId) { + return false; + } + + // If the active user is not unlocked, return false + const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); + if (authStatus !== AuthenticationStatus.Unlocked) { + return false; + } + + // If the active user is required to set/change their master password, return false + // Note that by this point we know that the authRequestUserId is the active UserId (see check above) + const forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(authRequestUserId), + ); + if (forceSetPasswordReason !== ForceSetPasswordReason.None) { + return false; + } + + // User meets conditions: they are the intended recipient, unlocked, and not required to set/change their master password + return true; + } + + setupUnlockListenersForProcessingAuthRequests(destroy$: Observable): void { + // When account switching to a user who is Unlocked, process any pending auth requests. + this.accountService.activeAccount$ + .pipe( + map((a) => a?.id), // Extract active userId + distinctUntilChanged(), // Only when userId actually changes + filter((userId) => userId != null), // Require a valid userId + switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user + filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked + tap(() => { + void this.processPendingAuthRequests(); + }), + takeUntil(destroy$), + ) + .subscribe(); + + // When the active account transitions TO Unlocked, process any pending auth requests. + this.authService.activeAccountStatus$ + .pipe( + startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission + pairwise(), // Compare previous and current statuses + filter( + ([prev, curr]) => + prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial) + ), + takeUntil(destroy$), + ) + .subscribe(() => { + void this.processPendingAuthRequests(); + }); + } + + /** + * Process notifications that have been received but didn't meet the conditions to display the + * approval dialog. + */ + private async processPendingAuthRequests(): Promise { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + // Only continue if the active user is not required to set/change their master password + const forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(activeUserId), + ); + if (forceSetPasswordReason !== ForceSetPasswordReason.None) { + return; + } + + // Prune any stale pending requests (older than 15 minutes) + // This comes from GlobalSettings.cs + // public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15); + const fifteenMinutesMs = 15 * 60 * 1000; + + await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs); + + const pendingAuthRequestsInState: PendingAuthUserMarker[] = + (await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? []; + + if (pendingAuthRequestsInState.length > 0) { + const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some( + (e) => e.userId === activeUserId, + ); + + if (pendingAuthRequestsForActiveUser) { + this.messagingService.send("openLoginApproval"); + } + } + } + + async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise { + throw new Error("handleAuthRequestNotificationClicked() not implemented for this client"); + } +} diff --git a/libs/common/src/auth/services/auth-request-answering/noop-auth-request-answering.service.ts b/libs/common/src/auth/services/auth-request-answering/noop-auth-request-answering.service.ts index 730362adfed..1d858516fe4 100644 --- a/libs/common/src/auth/services/auth-request-answering/noop-auth-request-answering.service.ts +++ b/libs/common/src/auth/services/auth-request-answering/noop-auth-request-answering.service.ts @@ -1,14 +1,22 @@ import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service"; import { UserId } from "@bitwarden/user-core"; -import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction"; -export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction { - constructor() {} +export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringService { + async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise { + throw new Error( + "activeUserMeetsConditionsToShowApprovalDialog() not implemented for this client", + ); + } - async receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise {} + setupUnlockListenersForProcessingAuthRequests(): void { + throw new Error( + "setupUnlockListenersForProcessingAuthRequests() not implemented for this client", + ); + } - async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise {} - - async processPendingAuthRequests(): Promise {} + async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise { + throw new Error("handleAuthRequestNotificationClicked() not implemented for this client"); + } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index e5c29636585..20da219e8d7 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -11,7 +11,6 @@ import { ServerConfig } from "../platform/abstractions/config/server-config"; // eslint-disable-next-line @bitwarden/platform/no-enums export enum FeatureFlag { /* Admin Console Team */ - CreateDefaultLocation = "pm-19467-create-default-location", AutoConfirm = "pm-19934-auto-confirm-organization-users", BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration", IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud", @@ -28,7 +27,6 @@ export enum FeatureFlag { TrialPaymentOptional = "PM-8163-trial-payment", PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button", PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", - PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service", PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", PM26462_Milestone_3 = "pm-26462-milestone-3", @@ -40,7 +38,6 @@ export enum FeatureFlag { ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption", LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2", - UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data", NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change", DataRecoveryTool = "pm-28813-data-recovery-tool", ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component", @@ -80,6 +77,9 @@ export enum FeatureFlag { /* UIF */ RouterFocusManagement = "router-focus-management", + + /* Secrets Manager */ + SM1719_RemoveSecretsManagerAds = "sm-1719-remove-secrets-manager-ads", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -97,7 +97,6 @@ const FALSE = false as boolean; */ export const DefaultFeatureFlagValue = { /* Admin Console Team */ - [FeatureFlag.CreateDefaultLocation]: FALSE, [FeatureFlag.AutoConfirm]: FALSE, [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, [FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE, @@ -136,7 +135,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.TrialPaymentOptional]: FALSE, [FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE, [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, - [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, [FeatureFlag.PM26462_Milestone_3]: FALSE, @@ -148,7 +146,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.ForceUpdateKDFSettings]: FALSE, [FeatureFlag.PM25174_DisableType0Decryption]: FALSE, [FeatureFlag.LinuxBiometricsV2]: FALSE, - [FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE, [FeatureFlag.NoLogoutOnKdfChange]: FALSE, [FeatureFlag.DataRecoveryTool]: FALSE, [FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE, @@ -166,6 +163,9 @@ export const DefaultFeatureFlagValue = { /* UIF */ [FeatureFlag.RouterFocusManagement]: FALSE, + + /* Secrets Manager */ + [FeatureFlag.SM1719_RemoveSecretsManagerAds]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts index 7aacc783e65..2795e4c3003 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts @@ -4,7 +4,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { mockAccountInfoWith } from "../../../../spec"; import { AccountService } from "../../../auth/abstractions/account.service"; @@ -33,7 +33,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => { let signalRNotificationConnectionService: MockProxy; let authService: MockProxy; let webPushNotificationConnectionService: MockProxy; - let authRequestAnsweringService: MockProxy; + let authRequestAnsweringService: MockProxy; let configService: MockProxy; let policyService: MockProxy; @@ -127,7 +127,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => { return webPushSupportStatusByUser.get(userId)!.asObservable(); }); - authRequestAnsweringService = mock(); + authRequestAnsweringService = mock(); policyService = mock(); @@ -270,13 +270,13 @@ describe("DefaultServerNotificationsService (multi-user)", () => { // allow async queue to drain await new Promise((resolve) => setTimeout(resolve, 0)); - expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", { - notificationId: "auth-id-2", - }); + // When authRequestAnsweringService.receivedPendingAuthRequest exists (Extension/Desktop), + // only that method is called. messagingService.send is only called for Web (NoopAuthRequestAnsweringService). expect(authRequestAnsweringService.receivedPendingAuthRequest).toHaveBeenCalledWith( mockUserId2, "auth-id-2", ); + expect(messagingService.send).not.toHaveBeenCalled(); subscription.unsubscribe(); }); diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts index 9c84981b7f9..f058e8794ac 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts @@ -6,7 +6,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, of, Subj import { LogoutReason } from "@bitwarden/auth/common"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { awaitAsync, mockAccountInfoWith } from "../../../../spec"; import { Matrix } from "../../../../spec/matrix"; @@ -42,7 +42,7 @@ describe("NotificationsService", () => { let signalRNotificationConnectionService: MockProxy; let authService: MockProxy; let webPushNotificationConnectionService: MockProxy; - let authRequestAnsweringService: MockProxy; + let authRequestAnsweringService: MockProxy; let configService: MockProxy; let policyService: MockProxy; @@ -72,7 +72,7 @@ describe("NotificationsService", () => { signalRNotificationConnectionService = mock(); authService = mock(); webPushNotificationConnectionService = mock(); - authRequestAnsweringService = mock(); + authRequestAnsweringService = mock(); configService = mock(); policyService = mock(); @@ -471,5 +471,41 @@ describe("NotificationsService", () => { ); }); }); + + describe("NotificationType.AuthRequest", () => { + it("should call receivedPendingAuthRequest when it exists (Extension/Desktop)", async () => { + authRequestAnsweringService.receivedPendingAuthRequest!.mockResolvedValue(undefined as any); + + const notification = new NotificationResponse({ + type: NotificationType.AuthRequest, + payload: { userId: mockUser1, id: "auth-request-123" }, + contextId: "different-app-id", + }); + + await sut["processNotification"](notification, mockUser1); + + expect(authRequestAnsweringService.receivedPendingAuthRequest).toHaveBeenCalledWith( + mockUser1, + "auth-request-123", + ); + expect(messagingService.send).not.toHaveBeenCalled(); + }); + + it("should call messagingService.send when receivedPendingAuthRequest does not exist (Web)", async () => { + authRequestAnsweringService.receivedPendingAuthRequest = undefined as any; + + const notification = new NotificationResponse({ + type: NotificationType.AuthRequest, + payload: { userId: mockUser1, id: "auth-request-456" }, + contextId: "different-app-id", + }); + + await sut["processNotification"](notification, mockUser1); + + expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", { + notificationId: "auth-request-456", + }); + }); + }); }); }); diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts index 5b026add1a2..83ea12bf154 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts @@ -17,7 +17,7 @@ import { import { LogoutReason } from "@bitwarden/auth/common"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data"; -import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { trackedMerge } from "@bitwarden/common/platform/misc"; @@ -67,7 +67,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer private readonly signalRConnectionService: SignalRConnectionService, private readonly authService: AuthService, private readonly webPushConnectionService: WebPushConnectionService, - private readonly authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction, + private readonly authRequestAnsweringService: AuthRequestAnsweringService, private readonly configService: ConfigService, private readonly policyService: InternalPolicyService, ) { @@ -250,26 +250,28 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer case NotificationType.SyncSendDelete: await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification); break; - case NotificationType.AuthRequest: - await this.authRequestAnsweringService.receivedPendingAuthRequest( - notification.payload.userId, - notification.payload.id, - ); - - /** - * This call is necessary for Desktop, which for the time being uses a noop for the - * authRequestAnsweringService.receivedPendingAuthRequest() call just above. Desktop - * will eventually use the new AuthRequestAnsweringService, at which point we can remove - * this second call. - * - * The Extension AppComponent has logic (see processingPendingAuth) that only allows one - * pending auth request to process at a time, so this second call will not cause any - * duplicate processing conflicts on Extension. - */ - this.messagingService.send("openLoginApproval", { - notificationId: notification.payload.id, - }); + case NotificationType.AuthRequest: { + // Only Extension and Desktop implement the AuthRequestAnsweringService + if (this.authRequestAnsweringService.receivedPendingAuthRequest) { + try { + await this.authRequestAnsweringService.receivedPendingAuthRequest( + notification.payload.userId, + notification.payload.id, + ); + } catch (error) { + this.logService.error(`Failed to process auth request notification: ${error}`); + } + } else { + // This call is necessary for Web, which uses a NoopAuthRequestAnsweringService + // that does not have a receivedPendingAuthRequest() method + this.messagingService.send("openLoginApproval", { + // Include the authRequestId so the DeviceManagementComponent can upsert the correct device. + // This will only matter if the user is on the /device-management screen when the auth request is received. + notificationId: notification.payload.id, + }); + } break; + } case NotificationType.SyncOrganizationStatusChanged: await this.syncService.fullSync(true); break; diff --git a/libs/common/src/services/api.service.spec.ts b/libs/common/src/services/api.service.spec.ts index 9ab84ecb16b..b5e72dfd899 100644 --- a/libs/common/src/services/api.service.spec.ts +++ b/libs/common/src/services/api.service.spec.ts @@ -449,4 +449,800 @@ describe("ApiService", () => { ).rejects.toThrow(InsecureUrlNotAllowedError); expect(nativeFetch).not.toHaveBeenCalled(); }); + + describe("When a 401 Unauthorized status is received", () => { + it("retries request with refreshed token when initial request with access token returns 401", async () => { + // This test verifies the 401 retry flow: + // 1. Initial request with valid token returns 401 (token expired server-side) + // 2. After 401, buildRequest is called again, which checks tokenNeedsRefresh + // 3. tokenNeedsRefresh returns true, triggering refreshToken via getActiveBearerToken + // 4. refreshToken makes an HTTP call to /connect/token to get new tokens + // 5. setTokens is called to store the new tokens, returning the refreshed access token + // 6. Request is retried with the refreshed token and succeeds + environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue( + of({ + getApiUrl: () => "https://example.com", + getIdentityUrl: () => "https://identity.example.com", + } satisfies Partial as Environment), + ); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("access_token"); + // First call (initial request): token doesn't need refresh yet + // Subsequent calls (after 401): token needs refresh, triggering the refresh flow + tokenService.tokenNeedsRefresh + .calledWith(testActiveUser) + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + + tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token"); + + tokenService.decodeAccessToken + .calledWith(testActiveUser) + .mockResolvedValue({ client_id: "web" }); + + tokenService.decodeAccessToken + .calledWith("new_access_token") + .mockResolvedValue({ sub: testActiveUser }); + + vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$ + .calledWith(testActiveUser) + .mockReturnValue(of(VaultTimeoutAction.Lock)); + + vaultTimeoutSettingsService.getVaultTimeoutByUserId$ + .calledWith(testActiveUser) + .mockReturnValue(of(VaultTimeoutStringType.Never)); + + tokenService.setTokens + .calledWith( + "new_access_token", + VaultTimeoutAction.Lock, + VaultTimeoutStringType.Never, + "new_refresh_token", + ) + .mockResolvedValue({ accessToken: "new_access_token" }); + + const nativeFetch = jest.fn, [request: Request]>(); + let callCount = 0; + + nativeFetch.mockImplementation((request) => { + callCount++; + + // First call: initial request with expired token returns 401 + if (callCount === 1) { + return Promise.resolve({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: "Unauthorized" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + } + + // Second call: token refresh request + if (callCount === 2 && request.url.includes("identity")) { + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + access_token: "new_access_token", + token_type: "Bearer", + refresh_token: "new_refresh_token", + }), + } satisfies Partial as Response); + } + + // Third call: retry with refreshed token succeeds + if (callCount === 3) { + expect(request.headers.get("Authorization")).toBe("Bearer new_access_token"); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: "success" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + } + + throw new Error(`Unexpected call #${callCount}: ${request.method} ${request.url}`); + }); + + sut.nativeFetch = nativeFetch; + + const response = await sut.send("GET", "/something", null, true, true, null, null); + + expect(nativeFetch).toHaveBeenCalledTimes(3); + expect(response).toEqual({ data: "success" }); + }); + + it("does not retry when request has no access token and returns 401", async () => { + environmentService.environment$ = of({ + getApiUrl: () => "https://example.com", + } satisfies Partial as Environment); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + const nativeFetch = jest.fn, [request: Request]>(); + + nativeFetch.mockImplementation((request) => { + return Promise.resolve({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: "Unauthorized" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + }); + + sut.nativeFetch = nativeFetch; + + await expect( + async () => await sut.send("GET", "/something", null, false, true, null, null), + ).rejects.toMatchObject({ message: "Unauthorized" }); + + // Should only be called once (no retry) + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + + it("does not retry when request returns non-401 error", async () => { + environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue( + of({ + getApiUrl: () => "https://example.com", + getIdentityUrl: () => "https://identity.example.com", + } satisfies Partial as Environment), + ); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("valid_token"); + tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false); + + const nativeFetch = jest.fn, [request: Request]>(); + + nativeFetch.mockImplementation((request) => { + return Promise.resolve({ + ok: false, + status: 400, + json: () => Promise.resolve({ message: "Bad Request" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + }); + + sut.nativeFetch = nativeFetch; + + await expect( + async () => await sut.send("GET", "/something", null, true, true, null, null), + ).rejects.toMatchObject({ message: "Bad Request" }); + + // Should only be called once (no retry for non-401 errors) + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + + it("does not attempt to log out unauthenticated user", async () => { + environmentService.environment$ = of({ + getApiUrl: () => "https://example.com", + } satisfies Partial as Environment); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + const nativeFetch = jest.fn, [request: Request]>(); + + nativeFetch.mockImplementation((request) => { + return Promise.resolve({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: "Unauthorized" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + }); + + sut.nativeFetch = nativeFetch; + + await expect( + async () => await sut.send("GET", "/something", null, false, true, null, null), + ).rejects.toMatchObject({ message: "Unauthorized" }); + + expect(logoutCallback).not.toHaveBeenCalled(); + }); + + it("does not retry when hasResponse is false", async () => { + environmentService.environment$ = of({ + getApiUrl: () => "https://example.com", + } satisfies Partial as Environment); + + environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue( + of({ + getApiUrl: () => "https://example.com", + getIdentityUrl: () => "https://identity.example.com", + } satisfies Partial as Environment), + ); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("expired_token"); + tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false); + + const nativeFetch = jest.fn, [request: Request]>(); + + nativeFetch.mockImplementation((request) => { + return Promise.resolve({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: "Unauthorized" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + }); + + sut.nativeFetch = nativeFetch; + + // When hasResponse is false, the method should throw even though no retry happens + await expect( + async () => await sut.send("POST", "/something", null, true, false, null, null), + ).rejects.toMatchObject({ message: "Unauthorized" }); + + // Should only be called once (no retry when hasResponse is false) + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + + it("uses original user token for retry even if active user changes between requests", async () => { + // Setup: Initial request is for testActiveUser, but during the retry, the active user switches + // to testInactiveUser. The retry should still use testActiveUser's refreshed token. + + let activeUserId = testActiveUser; + + // Mock accountService to return different active users based on when it's called + accountService.activeAccount$ = of({ + id: activeUserId, + ...mockAccountInfoWith({ + email: "user1@example.com", + name: "Test Name", + }), + } satisfies ObservedValueOf); + + environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue( + of({ + getApiUrl: () => "https://example.com", + getIdentityUrl: () => "https://identity.example.com", + } satisfies Partial as Environment), + ); + + environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValue( + of({ + getApiUrl: () => "https://inactive.example.com", + getIdentityUrl: () => "https://identity.inactive.example.com", + } satisfies Partial as Environment), + ); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + tokenService.getAccessToken + .calledWith(testActiveUser) + .mockResolvedValue("active_access_token"); + tokenService.tokenNeedsRefresh + .calledWith(testActiveUser) + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + + tokenService.getRefreshToken + .calledWith(testActiveUser) + .mockResolvedValue("active_refresh_token"); + + tokenService.decodeAccessToken + .calledWith(testActiveUser) + .mockResolvedValue({ client_id: "web" }); + + tokenService.decodeAccessToken + .calledWith("active_new_access_token") + .mockResolvedValue({ sub: testActiveUser }); + + vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$ + .calledWith(testActiveUser) + .mockReturnValue(of(VaultTimeoutAction.Lock)); + + vaultTimeoutSettingsService.getVaultTimeoutByUserId$ + .calledWith(testActiveUser) + .mockReturnValue(of(VaultTimeoutStringType.Never)); + + tokenService.setTokens + .calledWith( + "active_new_access_token", + VaultTimeoutAction.Lock, + VaultTimeoutStringType.Never, + "active_new_refresh_token", + ) + .mockResolvedValue({ accessToken: "active_new_access_token" }); + + // Mock tokens for inactive user (should NOT be used) + tokenService.getAccessToken + .calledWith(testInactiveUser) + .mockResolvedValue("inactive_access_token"); + + const nativeFetch = jest.fn, [request: Request]>(); + let callCount = 0; + + nativeFetch.mockImplementation((request) => { + callCount++; + + // First call: initial request with active user's token returns 401 + if (callCount === 1) { + expect(request.url).toBe("https://example.com/something"); + expect(request.headers.get("Authorization")).toBe("Bearer active_access_token"); + + // After the 401, simulate active user changing + activeUserId = testInactiveUser; + accountService.activeAccount$ = of({ + id: testInactiveUser, + ...mockAccountInfoWith({ + email: "user2@example.com", + name: "Inactive User", + }), + } satisfies ObservedValueOf); + + return Promise.resolve({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: "Unauthorized" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + } + + // Second call: token refresh request for ORIGINAL user (testActiveUser) + if (callCount === 2 && request.url.includes("identity")) { + expect(request.url).toContain("identity.example.com"); + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + access_token: "active_new_access_token", + token_type: "Bearer", + refresh_token: "active_new_refresh_token", + }), + } satisfies Partial as Response); + } + + // Third call: retry with ORIGINAL user's refreshed token, NOT the new active user's token + if (callCount === 3) { + expect(request.url).toBe("https://example.com/something"); + expect(request.headers.get("Authorization")).toBe("Bearer active_new_access_token"); + // Verify we're NOT using the inactive user's endpoint + expect(request.url).not.toContain("inactive"); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: "success with original user" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + } + + throw new Error(`Unexpected call #${callCount}: ${request.method} ${request.url}`); + }); + + sut.nativeFetch = nativeFetch; + + // Explicitly pass testActiveUser to ensure the request is for that specific user + const response = await sut.send("GET", "/something", null, testActiveUser, true, null, null); + + expect(nativeFetch).toHaveBeenCalledTimes(3); + expect(response).toEqual({ data: "success with original user" }); + + // Verify that inactive user's token was never requested + expect(tokenService.getAccessToken.calledWith(testInactiveUser)).not.toHaveBeenCalled(); + }); + + it("throws error when retry also returns 401", async () => { + environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue( + of({ + getApiUrl: () => "https://example.com", + getIdentityUrl: () => "https://identity.example.com", + } satisfies Partial as Environment), + ); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("access_token"); + // First call (initial request): token doesn't need refresh yet + // Subsequent calls (after 401): token needs refresh, triggering the refresh flow + tokenService.tokenNeedsRefresh + .calledWith(testActiveUser) + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + + tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token"); + + tokenService.decodeAccessToken + .calledWith(testActiveUser) + .mockResolvedValue({ client_id: "web" }); + + tokenService.decodeAccessToken + .calledWith("new_access_token") + .mockResolvedValue({ sub: testActiveUser }); + + vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$ + .calledWith(testActiveUser) + .mockReturnValue(of(VaultTimeoutAction.Lock)); + + vaultTimeoutSettingsService.getVaultTimeoutByUserId$ + .calledWith(testActiveUser) + .mockReturnValue(of(VaultTimeoutStringType.Never)); + + tokenService.setTokens + .calledWith( + "new_access_token", + VaultTimeoutAction.Lock, + VaultTimeoutStringType.Never, + "new_refresh_token", + ) + .mockResolvedValue({ accessToken: "new_access_token" }); + + const nativeFetch = jest.fn, [request: Request]>(); + let callCount = 0; + + nativeFetch.mockImplementation((request) => { + callCount++; + + // First call: initial request with expired token returns 401 + if (callCount === 1) { + return Promise.resolve({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: "Unauthorized" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + } + + // Second call: token refresh request + if (callCount === 2 && request.url.includes("identity")) { + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + access_token: "new_access_token", + token_type: "Bearer", + refresh_token: "new_refresh_token", + }), + } satisfies Partial as Response); + } + + // Third call: retry with refreshed token still returns 401 (user no longer has permission) + if (callCount === 3) { + return Promise.resolve({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: "Still Unauthorized" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + } + + throw new Error("Unexpected call"); + }); + + sut.nativeFetch = nativeFetch; + + await expect( + async () => await sut.send("GET", "/something", null, true, true, null, null), + ).rejects.toMatchObject({ message: "Still Unauthorized" }); + + expect(nativeFetch).toHaveBeenCalledTimes(3); + expect(logoutCallback).toHaveBeenCalledWith("invalidAccessToken"); + }); + + it("handles concurrent requests that both receive 401 and share token refresh", async () => { + // This test verifies the race condition scenario: + // 1. Request A starts with valid token + // 2. Request B starts with valid token + // 3. Request A gets 401, triggers refresh + // 4. Request B gets 401 while A is refreshing + // 5. Request B should wait for A's refresh to complete (via refreshTokenPromise cache) + // 6. Both requests retry with the new token + + environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue( + of({ + getApiUrl: () => "https://example.com", + getIdentityUrl: () => "https://identity.example.com", + } satisfies Partial as Environment), + ); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("expired_token"); + + // First two calls: token doesn't need refresh yet + // Subsequent calls: token needs refresh + tokenService.tokenNeedsRefresh + .calledWith(testActiveUser) + .mockResolvedValueOnce(false) // Request A initial + .mockResolvedValueOnce(false) // Request B initial + .mockResolvedValue(true); // Both retries after 401 + + tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token"); + + tokenService.decodeAccessToken + .calledWith(testActiveUser) + .mockResolvedValue({ client_id: "web" }); + + tokenService.decodeAccessToken + .calledWith("new_access_token") + .mockResolvedValue({ sub: testActiveUser }); + + vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$ + .calledWith(testActiveUser) + .mockReturnValue(of(VaultTimeoutAction.Lock)); + + vaultTimeoutSettingsService.getVaultTimeoutByUserId$ + .calledWith(testActiveUser) + .mockReturnValue(of(VaultTimeoutStringType.Never)); + + tokenService.setTokens + .calledWith( + "new_access_token", + VaultTimeoutAction.Lock, + VaultTimeoutStringType.Never, + "new_refresh_token", + ) + .mockResolvedValue({ accessToken: "new_access_token" }); + + const nativeFetch = jest.fn, [request: Request]>(); + let apiRequestCount = 0; + let refreshRequestCount = 0; + + nativeFetch.mockImplementation((request) => { + if (request.url.includes("identity")) { + refreshRequestCount++; + // Simulate slow token refresh to expose race condition + return new Promise((resolve) => + setTimeout( + () => + resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + access_token: "new_access_token", + token_type: "Bearer", + refresh_token: "new_refresh_token", + }), + } satisfies Partial as Response), + 100, + ), + ); + } + + apiRequestCount++; + const currentCall = apiRequestCount; + + // First two calls (Request A and B initial attempts): both return 401 + if (currentCall === 1 || currentCall === 2) { + return Promise.resolve({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: "Unauthorized" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + } + + // Third and fourth calls (retries after refresh): both succeed + if (currentCall === 3 || currentCall === 4) { + expect(request.headers.get("Authorization")).toBe("Bearer new_access_token"); + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ data: `success-${currentCall}` }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + } + + throw new Error(`Unexpected API call #${currentCall}: ${request.method} ${request.url}`); + }); + + sut.nativeFetch = nativeFetch; + + // Make two concurrent requests + const [responseA, responseB] = await Promise.all([ + sut.send("GET", "/endpoint-a", null, testActiveUser, true, null, null), + sut.send("GET", "/endpoint-b", null, testActiveUser, true, null, null), + ]); + + // Both requests should succeed + expect(responseA).toMatchObject({ data: expect.stringContaining("success") }); + expect(responseB).toMatchObject({ data: expect.stringContaining("success") }); + + // Verify only ONE token refresh was made (they shared the refresh) + expect(refreshRequestCount).toBe(1); + + // Verify the total number of API requests: 2 initial + 2 retries = 4 + expect(apiRequestCount).toBe(4); + + // Verify setTokens was only called once + expect(tokenService.setTokens).toHaveBeenCalledTimes(1); + }); + }); + + describe("When 403 Forbidden response is received from API request", () => { + it("logs out the authenticated user", async () => { + environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue( + of({ + getApiUrl: () => "https://example.com", + } satisfies Partial as Environment), + ); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("valid_token"); + tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false); + + const nativeFetch = jest.fn, [request: Request]>(); + + nativeFetch.mockImplementation((request) => { + return Promise.resolve({ + ok: false, + status: 403, + json: () => Promise.resolve({ message: "Forbidden" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + }); + + sut.nativeFetch = nativeFetch; + + await expect( + async () => await sut.send("GET", "/something", null, true, true, null, null), + ).rejects.toMatchObject({ message: "Forbidden" }); + + expect(logoutCallback).toHaveBeenCalledWith("invalidAccessToken"); + }); + + it("does not attempt to log out unauthenticated user", async () => { + environmentService.environment$ = of({ + getApiUrl: () => "https://example.com", + } satisfies Partial as Environment); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + const nativeFetch = jest.fn, [request: Request]>(); + + nativeFetch.mockImplementation((request) => { + return Promise.resolve({ + ok: false, + status: 403, + json: () => Promise.resolve({ message: "Forbidden" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + }); + + sut.nativeFetch = nativeFetch; + + await expect( + async () => await sut.send("GET", "/something", null, false, true, null, null), + ).rejects.toMatchObject({ message: "Forbidden" }); + + expect(logoutCallback).not.toHaveBeenCalled(); + }); + }); }); diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index c60f6c5e907..10f349fbec7 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -74,7 +74,7 @@ import { BillingHistoryResponse } from "../billing/models/response/billing-histo import { PaymentResponse } from "../billing/models/response/payment.response"; import { PlanResponse } from "../billing/models/response/plan.response"; import { SubscriptionResponse } from "../billing/models/response/subscription.response"; -import { ClientType, DeviceType } from "../enums"; +import { ClientType, DeviceType, HttpStatusCode } from "../enums"; import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request"; import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request"; import { VaultTimeoutSettingsService } from "../key-management/vault-timeout"; @@ -1252,8 +1252,8 @@ export class ApiService implements ApiServiceAbstraction { }), ); - if (response.status !== 200) { - const error = await this.handleError(response, false, true); + if (response.status !== HttpStatusCode.Ok) { + const error = await this.handleApiRequestError(response, true); return Promise.reject(error); } @@ -1283,8 +1283,8 @@ export class ApiService implements ApiServiceAbstraction { }), ); - if (response.status !== 200) { - const error = await this.handleError(response, false, true); + if (response.status !== HttpStatusCode.Ok) { + const error = await this.handleApiRequestError(response, true); return Promise.reject(error); } } @@ -1301,14 +1301,12 @@ export class ApiService implements ApiServiceAbstraction { }), ); - if (response.status !== 200) { - const error = await this.handleError(response, false, true); + if (response.status !== HttpStatusCode.Ok) { + const error = await this.handleApiRequestError(response, true); return Promise.reject(error); } } - // Helpers - async getActiveBearerToken(userId: UserId): Promise { let accessToken = await this.tokenService.getAccessToken(userId); if (await this.tokenService.tokenNeedsRefresh(userId)) { @@ -1370,7 +1368,7 @@ export class ApiService implements ApiServiceAbstraction { const body = await response.json(); return new SsoPreValidateResponse(body); } else { - const error = await this.handleError(response, false, true); + const error = await this.handleApiRequestError(response, false); return Promise.reject(error); } } @@ -1525,7 +1523,7 @@ export class ApiService implements ApiServiceAbstraction { ); return refreshedTokens.accessToken; } else { - const error = await this.handleError(response, true, true); + const error = await this.handleTokenRefreshRequestError(response); return Promise.reject(error); } } @@ -1580,6 +1578,89 @@ export class ApiService implements ApiServiceAbstraction { apiUrl?: string | null, alterHeaders?: (headers: Headers) => void, ): Promise { + // We assume that if there is a UserId making the request, it is also an authenticated + // request and we will attempt to add an access token to the request. + const userIdMakingRequest = await this.getUserIdMakingRequest(authedOrUserId); + + const environment = await firstValueFrom( + userIdMakingRequest == null + ? this.environmentService.environment$ + : this.environmentService.getEnvironment$(userIdMakingRequest), + ); + apiUrl = Utils.isNullOrWhitespace(apiUrl) ? environment.getApiUrl() : apiUrl; + + const requestUrl = await this.buildSafeApiRequestUrl(apiUrl, path); + + let request = await this.buildRequest( + method, + userIdMakingRequest, + environment, + hasResponse, + body, + alterHeaders, + ); + + let response = await this.fetch(this.httpOperations.createRequest(requestUrl, request)); + + // First, check to see if we were making an authenticated request and received an Unauthorized (401) + // response. This could mean that we attempted to make a request with an expired access token. + // If so, attempt to refresh the token and try again. + if ( + hasResponse && + userIdMakingRequest != null && + response.status === HttpStatusCode.Unauthorized + ) { + this.logService.warning( + "Unauthorized response received for request to " + path + ". Attempting request again.", + ); + request = await this.buildRequest( + method, + userIdMakingRequest, + environment, + hasResponse, + body, + alterHeaders, + ); + response = await this.fetch(this.httpOperations.createRequest(requestUrl, request)); + } + + // At this point we are processing either the initial response or the response for the retry with the refreshed + // access token. + const responseType = response.headers.get("content-type"); + const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1; + const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1; + if (hasResponse && response.status === HttpStatusCode.Ok && responseIsJson) { + const responseJson = await response.json(); + return responseJson; + } else if (hasResponse && response.status === HttpStatusCode.Ok && responseIsCsv) { + return await response.text(); + } else if ( + response.status !== HttpStatusCode.Ok && + response.status !== HttpStatusCode.NoContent + ) { + const error = await this.handleApiRequestError(response, userIdMakingRequest != null); + return Promise.reject(error); + } + } + + private buildSafeApiRequestUrl(apiUrl: string, path: string): string { + const pathParts = path.split("?"); + + // Check for path traversal patterns from any URL. + const fullUrlPath = apiUrl + pathParts[0] + (pathParts.length > 1 ? `?${pathParts[1]}` : ""); + + const isInvalidUrl = Utils.invalidUrlPatterns(fullUrlPath); + if (isInvalidUrl) { + throw new Error("The request URL contains dangerous patterns."); + } + + const requestUrl = + apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : ""); + + return requestUrl; + } + + private async getUserIdMakingRequest(authedOrUserId: UserId | boolean): Promise { if (authedOrUserId == null) { throw new Error("A user id was given but it was null, cannot complete API request."); } @@ -1591,29 +1672,19 @@ export class ApiService implements ApiServiceAbstraction { } else if (typeof authedOrUserId === "string") { userId = authedOrUserId; } + return userId; + } - const env = await firstValueFrom( - userId == null - ? this.environmentService.environment$ - : this.environmentService.getEnvironment$(userId), - ); - apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl; - - const pathParts = path.split("?"); - // Check for path traversal patterns from any URL. - const fullUrlPath = apiUrl + pathParts[0] + (pathParts.length > 1 ? `?${pathParts[1]}` : ""); - - const isInvalidUrl = Utils.invalidUrlPatterns(fullUrlPath); - if (isInvalidUrl) { - throw new Error("The request URL contains dangerous patterns."); - } - - // Prevent directory traversal from malicious paths - const requestUrl = - apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : ""); - + private async buildRequest( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + userForAccessToken: UserId | null, + environment: Environment, + hasResponse: boolean, + body: string, + alterHeaders?: (headers: Headers) => void, + ): Promise { const [requestHeaders, requestBody] = await this.buildHeadersAndBody( - userId, + userForAccessToken, hasResponse, body, alterHeaders, @@ -1621,29 +1692,17 @@ export class ApiService implements ApiServiceAbstraction { const requestInit: RequestInit = { cache: "no-store", - credentials: await this.getCredentials(env), + credentials: await this.getCredentials(environment), method: method, }; requestInit.headers = requestHeaders; requestInit.body = requestBody; - const response = await this.fetch(this.httpOperations.createRequest(requestUrl, requestInit)); - const responseType = response.headers.get("content-type"); - const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1; - const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1; - if (hasResponse && response.status === 200 && responseIsJson) { - const responseJson = await response.json(); - return responseJson; - } else if (hasResponse && response.status === 200 && responseIsCsv) { - return await response.text(); - } else if (response.status !== 200 && response.status !== 204) { - const error = await this.handleError(response, false, userId != null); - return Promise.reject(error); - } + return requestInit; } private async buildHeadersAndBody( - userToAuthenticate: UserId | null, + userForAccessToken: UserId | null, hasResponse: boolean, body: any, alterHeaders: (headers: Headers) => void, @@ -1665,8 +1724,8 @@ export class ApiService implements ApiServiceAbstraction { if (alterHeaders != null) { alterHeaders(headers); } - if (userToAuthenticate != null) { - const authHeader = await this.getActiveBearerToken(userToAuthenticate); + if (userForAccessToken != null) { + const authHeader = await this.getActiveBearerToken(userForAccessToken); headers.set("Authorization", "Bearer " + authHeader); } else { // For unauthenticated requests, we need to tell the server what the device is for flag targeting, @@ -1692,32 +1751,59 @@ export class ApiService implements ApiServiceAbstraction { return [headers, requestBody]; } - private async handleError( + /** + * Handle an error response from a request to the Bitwarden API. + * If the request is made with an access token (aka the user is authenticated), + * and we receive a 401 or 403 response, we will log the user out, as this indicates + * that the access token used on the request is either expired or does not have the appropriate permissions. + * It is unlikely that it is expired, as we attempt to refresh the token on initial failure. + * @param response The response from the API request + * @param userIsAuthenticated A boolean indicating whether this is an authenticated request. + * @returns An ErrorResponse with a message based on the response status. + */ + private async handleApiRequestError( response: Response, - tokenError: boolean, - authed: boolean, + userIsAuthenticated: boolean, ): Promise { + if ( + userIsAuthenticated && + (response.status === HttpStatusCode.Unauthorized || + response.status === HttpStatusCode.Forbidden) + ) { + await this.logoutCallback("invalidAccessToken"); + } + + const responseJson = await this.getJsonResponse(response); + return new ErrorResponse(responseJson, response.status); + } + + /** + * Handle an error response when trying to refresh an access token. + * If the error indicates that the user's session has expired, it will log the user out. + * @param response The response from the token refresh request. + * @returns An ErrorResponse with a message based on the response status. + */ + private async handleTokenRefreshRequestError(response: Response): Promise { + const responseJson = await this.getJsonResponse(response); + + // IdentityServer will return an invalid_grant response if the refresh token has expired. + // This means that the user's session has expired, and they need to log out. + // We issue the logoutCallback() to log the user out through messaging. + if (response.status === HttpStatusCode.BadRequest && responseJson?.error === "invalid_grant") { + await this.logoutCallback("sessionExpired"); + } + + return new ErrorResponse(responseJson, response.status, true); + } + + private async getJsonResponse(response: Response): Promise { let responseJson: any = null; if (this.isJsonResponse(response)) { responseJson = await response.json(); } else if (this.isTextPlainResponse(response)) { responseJson = { Message: await response.text() }; } - - if (authed) { - if ( - response.status === 401 || - response.status === 403 || - (tokenError && - response.status === 400 && - responseJson != null && - responseJson.error === "invalid_grant") - ) { - await this.logoutCallback("invalidGrantError"); - } - } - - return new ErrorResponse(responseJson, response.status, tokenError); + return responseJson; } private qsStringify(params: any): string { diff --git a/libs/common/src/vault/abstractions/cipher-encryption.service.ts b/libs/common/src/vault/abstractions/cipher-encryption.service.ts index fdd42c0acf2..a3b824fd46e 100644 --- a/libs/common/src/vault/abstractions/cipher-encryption.service.ts +++ b/libs/common/src/vault/abstractions/cipher-encryption.service.ts @@ -20,6 +20,16 @@ export abstract class CipherEncryptionService { */ abstract encrypt(model: CipherView, userId: UserId): Promise; + /** + * Encrypts multiple ciphers using the SDK for the given userId. + * + * @param models The cipher views to encrypt + * @param userId The user ID to initialize the SDK client with + * + * @returns A promise that resolves to an array of encryption contexts + */ + abstract encryptMany(models: CipherView[], userId: UserId): Promise; + /** * Move the cipher to the specified organization by re-encrypting its keys with the organization's key. * The cipher.organizationId will be updated to the new organizationId. diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 0d3a0b99fcb..203984075f7 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -50,6 +50,15 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + /** + * Encrypts multiple ciphers for the given user. + * + * @param models The cipher views to encrypt + * @param userId The user ID to encrypt for + * + * @returns A promise that resolves to an array of encryption contexts + */ + abstract encryptMany(models: CipherView[], userId: UserId): Promise; abstract encryptFields(fieldsModel: FieldView[], key: SymmetricCryptoKey): Promise; abstract encryptField(fieldModel: FieldView, key: SymmetricCryptoKey): Promise; abstract get(id: string, userId: UserId): Promise; diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 50823807fcf..153bb01403c 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -868,7 +868,7 @@ describe("Cipher Service", () => { const result = await firstValueFrom( stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).state$, ); - expect(result[cipherId].archivedDate).toBeNull(); + expect(result[cipherId].archivedDate).toEqual("2024-01-01T12:00:00.000Z"); expect(result[cipherId].deletedDate).toBeDefined(); }); }); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 3c44b854de7..2e0adc892e3 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -340,6 +340,24 @@ export class CipherService implements CipherServiceAbstraction { } } + async encryptMany(models: CipherView[], userId: UserId): Promise { + const sdkEncryptionEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM22136_SdkCipherEncryption, + ); + + if (sdkEncryptionEnabled) { + return await this.cipherEncryptionService.encryptMany(models, userId); + } + + // Fallback to sequential encryption if SDK disabled + const results: EncryptionContext[] = []; + for (const model of models) { + const result = await this.encrypt(model, userId); + results.push(result); + } + return results; + } + async encryptAttachments( attachmentsModel: AttachmentView[], key: SymmetricCryptoKey, @@ -1461,7 +1479,6 @@ export class CipherService implements CipherServiceAbstraction { return; } ciphers[cipherId].deletedDate = new Date().toISOString(); - ciphers[cipherId].archivedDate = null; }; if (typeof id === "string") { diff --git a/libs/common/src/vault/services/default-cipher-archive.service.spec.ts b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts index 2f5e69d65ed..2078d1f29ea 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.spec.ts @@ -165,6 +165,7 @@ describe("DefaultCipherArchiveService", () => { mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers)); mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + featureFlag.next(true); const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId)); diff --git a/libs/common/src/vault/services/default-cipher-archive.service.ts b/libs/common/src/vault/services/default-cipher-archive.service.ts index c1daade0dad..12d67ab07f9 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.ts @@ -71,8 +71,15 @@ export class DefaultCipherArchiveService implements CipherArchiveService { /** Returns true when the user has previously archived ciphers but lost their premium membership. */ showSubscriptionEndedMessaging$(userId: UserId): Observable { - return combineLatest([this.archivedCiphers$(userId), this.userHasPremium$(userId)]).pipe( - map(([archivedCiphers, hasPremium]) => archivedCiphers.length > 0 && !hasPremium), + return combineLatest([ + this.archivedCiphers$(userId), + this.userHasPremium$(userId), + this.hasArchiveFlagEnabled$, + ]).pipe( + map( + ([archivedCiphers, hasPremium, flagEnabled]) => + flagEnabled && archivedCiphers.length > 0 && !hasPremium, + ), shareReplay({ refCount: true, bufferSize: 1 }), ); } diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts index f54dfa17a38..a0ca4833b92 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.spec.ts @@ -253,6 +253,68 @@ describe("DefaultCipherEncryptionService", () => { }); }); + describe("encryptMany", () => { + it("should encrypt multiple ciphers", async () => { + const cipherView2 = new CipherView(cipherObj); + cipherView2.name = "test-name-2"; + const cipherView3 = new CipherView(cipherObj); + cipherView3.name = "test-name-3"; + + const ciphers = [cipherViewObj, cipherView2, cipherView3]; + + const expectedCipher1: Cipher = { + id: cipherId as string, + type: CipherType.Login, + name: "encrypted-name-1", + } as unknown as Cipher; + + const expectedCipher2: Cipher = { + id: cipherId as string, + type: CipherType.Login, + name: "encrypted-name-2", + } as unknown as Cipher; + + const expectedCipher3: Cipher = { + id: cipherId as string, + type: CipherType.Login, + name: "encrypted-name-3", + } as unknown as Cipher; + + mockSdkClient.vault().ciphers().encrypt.mockReturnValue({ + cipher: sdkCipher, + encryptedFor: userId, + }); + + jest + .spyOn(Cipher, "fromSdkCipher") + .mockReturnValueOnce(expectedCipher1) + .mockReturnValueOnce(expectedCipher2) + .mockReturnValueOnce(expectedCipher3); + + const results = await cipherEncryptionService.encryptMany(ciphers, userId); + + expect(results).toBeDefined(); + expect(results.length).toBe(3); + expect(results[0].cipher).toEqual(expectedCipher1); + expect(results[1].cipher).toEqual(expectedCipher2); + expect(results[2].cipher).toEqual(expectedCipher3); + + expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledTimes(3); + + expect(results[0].encryptedFor).toBe(userId); + expect(results[1].encryptedFor).toBe(userId); + expect(results[2].encryptedFor).toBe(userId); + }); + + it("should handle empty array", async () => { + const results = await cipherEncryptionService.encryptMany([], userId); + + expect(results).toBeDefined(); + expect(results.length).toBe(0); + expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled(); + }); + }); + describe("encryptCipherForRotation", () => { it("should call the sdk method to encrypt the cipher with a new key for rotation", async () => { mockSdkClient.vault().ciphers().encrypt_cipher_for_rotation.mockReturnValue({ diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.ts b/libs/common/src/vault/services/default-cipher-encryption.service.ts index f1b737ed50f..588265846e0 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts @@ -51,6 +51,44 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { ); } + async encryptMany(models: CipherView[], userId: UserId): Promise { + if (!models || models.length === 0) { + return []; + } + + return firstValueFrom( + this.sdkService.userClient$(userId).pipe( + map((sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + + using ref = sdk.take(); + + const results: EncryptionContext[] = []; + + // TODO: https://bitwarden.atlassian.net/browse/PM-30580 + // Replace this loop with a native SDK encryptMany method for better performance. + for (const model of models) { + const sdkCipherView = this.toSdkCipherView(model, ref.value); + const encryptionContext = ref.value.vault().ciphers().encrypt(sdkCipherView); + + results.push({ + cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!, + encryptedFor: uuidAsString(encryptionContext.encryptedFor) as UserId, + }); + } + + return results; + }), + catchError((error: unknown) => { + this.logService.error(`Failed to encrypt ciphers in batch: ${error}`); + return EMPTY; + }), + ), + ); + } + async moveToOrganization( model: CipherView, organizationId: OrganizationId, diff --git a/libs/common/src/vault/services/default-cipher-risk.service.spec.ts b/libs/common/src/vault/services/default-cipher-risk.service.spec.ts index e5231241462..fad5e963113 100644 --- a/libs/common/src/vault/services/default-cipher-risk.service.spec.ts +++ b/libs/common/src/vault/services/default-cipher-risk.service.spec.ts @@ -250,6 +250,38 @@ describe("DefaultCipherRiskService", () => { expect.any(Object), ); }); + + it("should filter out deleted Login ciphers", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + mockCipherRiskClient.compute_risk.mockResolvedValue([]); + + const activeCipher = new CipherView(); + activeCipher.id = mockCipherId1; + activeCipher.type = CipherType.Login; + activeCipher.login = new LoginView(); + activeCipher.login.password = "password1"; + activeCipher.deletedDate = undefined; + + const deletedCipher = new CipherView(); + deletedCipher.id = mockCipherId2; + deletedCipher.type = CipherType.Login; + deletedCipher.login = new LoginView(); + deletedCipher.login.password = "password2"; + deletedCipher.deletedDate = new Date(); + + await cipherRiskService.computeRiskForCiphers([activeCipher, deletedCipher], mockUserId); + + expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith( + [ + expect.objectContaining({ + id: expect.anything(), + password: "password1", + }), + ], + expect.any(Object), + ); + }); }); describe("buildPasswordReuseMap", () => { @@ -284,6 +316,41 @@ describe("DefaultCipherRiskService", () => { ]); expect(result).toEqual(mockReuseMap); }); + + it("should exclude deleted ciphers when building password reuse map", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + + const mockReuseMap = { + password1: 1, + }; + + mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap); + + const activeCipher = new CipherView(); + activeCipher.id = mockCipherId1; + activeCipher.type = CipherType.Login; + activeCipher.login = new LoginView(); + activeCipher.login.password = "password1"; + activeCipher.deletedDate = undefined; + + const deletedCipherWithSamePassword = new CipherView(); + deletedCipherWithSamePassword.id = mockCipherId2; + deletedCipherWithSamePassword.type = CipherType.Login; + deletedCipherWithSamePassword.login = new LoginView(); + deletedCipherWithSamePassword.login.password = "password1"; + deletedCipherWithSamePassword.deletedDate = new Date(); + + const result = await cipherRiskService.buildPasswordReuseMap( + [activeCipher, deletedCipherWithSamePassword], + mockUserId, + ); + + expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([ + expect.objectContaining({ password: "password1" }), + ]); + expect(result).toEqual(mockReuseMap); + }); }); describe("computeCipherRiskForUser", () => { diff --git a/libs/common/src/vault/services/default-cipher-risk.service.ts b/libs/common/src/vault/services/default-cipher-risk.service.ts index 4b4558e5e7a..5f424fdd7a2 100644 --- a/libs/common/src/vault/services/default-cipher-risk.service.ts +++ b/libs/common/src/vault/services/default-cipher-risk.service.ts @@ -71,7 +71,6 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction { passwordMap, checkExposed, }); - return results[0]; } @@ -103,7 +102,8 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction { return ( cipher.type === CipherType.Login && cipher.login?.password != null && - cipher.login.password !== "" + cipher.login.password !== "" && + !cipher.isDeleted ); }) .map( diff --git a/libs/components/src/dialog/dialog.service.stories.ts b/libs/components/src/dialog/dialog.service.stories.ts index 4e5c718e494..26f2b585f2d 100644 --- a/libs/components/src/dialog/dialog.service.stories.ts +++ b/libs/components/src/dialog/dialog.service.stories.ts @@ -31,7 +31,11 @@ interface Animal { - + + + `, imports: [ButtonModule, LayoutComponent], @@ -63,13 +67,29 @@ class StoryDialogComponent { }, }); } + + openSmallDrawer() { + this.dialogService.openDrawer(SmallDrawerContentComponent, { + data: { + animal: "panda", + }, + }); + } + + openLargeDrawer() { + this.dialogService.openDrawer(LargeDrawerContentComponent, { + data: { + animal: "panda", + }, + }); + } } // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: ` - + Dialog body text goes here.
@@ -100,7 +120,6 @@ class StoryDialogContentComponent { template: ` Dialog body text goes here. @@ -125,6 +144,64 @@ class NonDismissableContentComponent { } } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + template: ` + + + Dialog body text goes here. +
+ Animal: {{ animal }} +
+ + + + +
+ `, + imports: [DialogModule, ButtonModule], +}) +class SmallDrawerContentComponent { + dialogRef = inject(DialogRef); + private data = inject(DIALOG_DATA); + + get animal() { + return this.data?.animal; + } +} + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + template: ` + + + Dialog body text goes here. +
+ Animal: {{ animal }} +
+ + + + +
+ `, + imports: [DialogModule, ButtonModule], +}) +class LargeDrawerContentComponent { + dialogRef = inject(DialogRef); + private data = inject(DIALOG_DATA); + + get animal() { + return this.data?.animal; + } +} + export default { title: "Component Library/Dialogs/Service", component: StoryDialogComponent, @@ -206,3 +283,21 @@ export const Drawer: Story = { await userEvent.click(button); }, }; + +export const DrawerSmall: Story = { + play: async (context) => { + const canvas = context.canvasElement; + + const button = getAllByRole(canvas, "button")[3]; + await userEvent.click(button); + }, +}; + +export const DrawerLarge: Story = { + play: async (context) => { + const canvas = context.canvasElement; + + const button = getAllByRole(canvas, "button")[4]; + await userEvent.click(button); + }, +}; diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 22aa99c44cb..58364dfd045 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -2,7 +2,7 @@
("default"); + readonly dialogSize = input("default"); /** * Title to show in the dialog's header @@ -100,21 +114,31 @@ export class DialogComponent { private readonly animationCompleted = signal(false); + protected readonly width = computed(() => { + const size = this.dialogSize() ?? "default"; + const isDrawer = this.dialogRef?.isDrawer; + + if (isDrawer) { + return drawerSizeToWidth[size]; + } + + return dialogSizeToWidth[size]; + }); + protected readonly classes = computed(() => { // `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header const baseClasses = ["tw-flex", "tw-flex-col", "tw-w-screen"]; - const sizeClasses = this.dialogRef?.isDrawer - ? ["tw-h-full", "md:tw-w-[23rem]"] - : ["md:tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"]; + const sizeClasses = this.dialogRef?.isDrawer ? ["tw-h-full"] : ["md:tw-p-4", "tw-max-h-[90vh]"]; + const size = this.dialogSize() ?? "default"; const animationClasses = this.disableAnimations() || this.animationCompleted() || this.dialogRef?.isDrawer ? [] - : this.dialogSize() === "small" + : size === "small" ? ["tw-animate-slide-down"] : ["tw-animate-slide-up", "md:tw-animate-slide-down"]; - return [...baseClasses, this.width, ...sizeClasses, ...animationClasses]; + return [...baseClasses, this.width(), ...sizeClasses, ...animationClasses]; }); handleEsc(event: Event) { @@ -124,20 +148,6 @@ export class DialogComponent { } } - get width() { - switch (this.dialogSize()) { - case "small": { - return "md:tw-max-w-sm"; - } - case "large": { - return "md:tw-max-w-3xl"; - } - default: { - return "md:tw-max-w-xl"; - } - } - } - onAnimationEnd() { this.animationCompleted.set(true); } diff --git a/libs/components/src/header/header.component.ts b/libs/components/src/header/header.component.ts index 08cd91ea206..44b0c063d89 100644 --- a/libs/components/src/header/header.component.ts +++ b/libs/components/src/header/header.component.ts @@ -1,8 +1,11 @@ import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { TypographyDirective } from "../typography/typography.directive"; + @Component({ selector: "bit-header", templateUrl: "./header.component.html", + imports: [TypographyDirective], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, }) diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index a5cb1d5a6b9..1790fea179a 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -14,6 +14,7 @@ [ariaLabel]="ariaLabel()" [hideActiveStyles]="parentHideActiveStyles()" [ariaCurrentWhenActive]="ariaCurrent()" + [forceActiveStyles]="forceActiveStyles()" > - - - - -
- - -

{{ "or" | i18n }}

- - - - - - - - - - -
- - } diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 5d35746ff19..054212f8851 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -1,10 +1,8 @@ -import { DebugElement } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; -import { firstValueFrom, interval, map, of, takeWhile, timeout } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; import { ZXCVBNResult } from "zxcvbn"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -13,20 +11,13 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; -import { - MasterPasswordVerification, - MasterPasswordVerificationResponse, -} from "@bitwarden/common/auth/types/verification"; import { ClientType, DeviceType } from "@bitwarden/common/enums"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -36,7 +27,7 @@ import { SyncService } from "@bitwarden/common/platform/sync"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { UserId } from "@bitwarden/common/types/guid"; -import { MasterKey, UserKey } from "@bitwarden/common/types/key"; +import { UserKey } from "@bitwarden/common/types/key"; import { AnonLayoutWrapperDataService, AsyncActionsModule, @@ -94,7 +85,6 @@ describe("LockComponent", () => { const mockAnonLayoutWrapperDataService = mock(); const mockBroadcasterService = mock(); const mockEncryptedMigrator = mock(); - const mockConfigService = mock(); const mockActivatedRoute = { snapshot: { paramMap: { @@ -161,7 +151,6 @@ describe("LockComponent", () => { { provide: BroadcasterService, useValue: mockBroadcasterService }, { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: EncryptedMigrator, useValue: mockEncryptedMigrator }, - { provide: ConfigService, useValue: mockConfigService }, ], }) .overrideProvider(DialogService, { useValue: mockDialogService }) @@ -171,207 +160,6 @@ describe("LockComponent", () => { component = fixture.componentInstance; }); - describe("when master password unlock is active", () => { - let form: DebugElement; - - beforeEach(async () => { - const unlockOptions: UnlockOptions = { - masterPassword: { enabled: true }, - pin: { enabled: false }, - biometrics: { - enabled: false, - biometricsStatus: BiometricsStatus.NotEnabledLocally, - }, - }; - - component.activeUnlockOption = UnlockOption.MasterPassword; - mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(unlockOptions)); - await mockAccountService.switchAccount(userId); - mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web); - - mockI18nService.t.mockImplementation((key: string) => { - switch (key) { - case "unlock": - return "Unlock"; - case "logOut": - return "Log Out"; - case "logOutConfirmation": - return "Confirm Log Out"; - case "masterPass": - return "Master Password"; - } - return ""; - }); - - // Trigger ngOnInit - fixture.detectChanges(); - - // Wait for html loading to complete - await firstValueFrom( - interval(10).pipe( - map(() => component["loading"]), - takeWhile((loading) => loading, true), - timeout(5000), - ), - ); - - // Wait for html to render - fixture.detectChanges(); - - form = fixture.debugElement.query(By.css("form")); - }); - - describe("form rendering", () => { - it("should render form with label", () => { - expect(form).toBeTruthy(); - expect(form.nativeElement).toBeInstanceOf(HTMLFormElement); - - const bitLabel = form.query(By.css("bit-label")); - expect(bitLabel).toBeTruthy(); - expect(bitLabel.nativeElement).toBeInstanceOf(HTMLElement); - expect((bitLabel.nativeElement as HTMLElement).textContent?.trim()).toBe("Master Password"); - }); - - it("should render master password input field", () => { - const input = form.query(By.css('input[formControlName="masterPassword"]')); - - expect(input).toBeTruthy(); - expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); - const inputElement = input.nativeElement as HTMLInputElement; - expect(inputElement.type).toEqual("password"); - expect(inputElement.name).toEqual("masterPassword"); - expect(inputElement.required).toEqual(true); - expect(inputElement.attributes).toHaveProperty("bitInput"); - }); - - it("should render password toggle button", () => { - const toggleButton = form.query(By.css("button[bitPasswordInputToggle]")); - - expect(toggleButton).toBeTruthy(); - expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement); - const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement; - expect(toggleButtonElement.type).toEqual("button"); - expect(toggleButtonElement.attributes).toHaveProperty("bitIconButton"); - }); - - it("should render unlock submit button", () => { - const submitButton = form.query(By.css('button[type="submit"]')); - - expect(submitButton).toBeTruthy(); - expect(submitButton.nativeElement).toBeInstanceOf(HTMLButtonElement); - const submitButtonElement = submitButton.nativeElement as HTMLButtonElement; - expect(submitButtonElement.type).toEqual("submit"); - expect(submitButtonElement.attributes).toHaveProperty("bitButton"); - expect(submitButtonElement.attributes).toHaveProperty("bitFormButton"); - expect(submitButtonElement.textContent?.trim()).toEqual("Unlock"); - }); - - it("should render logout button", () => { - const logoutButton = form.query( - By.css('button[type="button"]:not([bitPasswordInputToggle])'), - ); - - expect(logoutButton).toBeTruthy(); - expect(logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement); - const logoutButtonElement = logoutButton.nativeElement as HTMLButtonElement; - expect(logoutButtonElement.type).toEqual("button"); - expect(logoutButtonElement.textContent?.trim()).toEqual("Log Out"); - }); - }); - - describe("unlock", () => { - it("should unlock with master password when unlock button is clicked", async () => { - const unlockViaMasterPasswordFunction = jest - .spyOn(component, "unlockViaMasterPassword") - .mockImplementation(); - const submitButton = form.query(By.css('button[type="submit"]')); - expect(submitButton).toBeTruthy(); - expect(submitButton.nativeElement).toBeInstanceOf(HTMLButtonElement); - const submitButtonElement = submitButton.nativeElement as HTMLButtonElement; - submitButtonElement.click(); - - expect(unlockViaMasterPasswordFunction).toHaveBeenCalled(); - }); - }); - - describe("logout", () => { - it("should logout when logout button is clicked", async () => { - const logOut = jest.spyOn(component, "logOut").mockImplementation(); - const logoutButton = form.query( - By.css('button[type="button"]:not([bitPasswordInputToggle])'), - ); - - expect(logoutButton).toBeTruthy(); - expect(logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement); - const logoutButtonElement = logoutButton.nativeElement as HTMLButtonElement; - - logoutButtonElement.click(); - - expect(logOut).toHaveBeenCalled(); - }); - }); - - describe("password input", () => { - it("should bind form input to masterPassword form control", async () => { - const input = form.query(By.css('input[formControlName="masterPassword"]')); - expect(input).toBeTruthy(); - expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); - expect(component.formGroup).toBeTruthy(); - const masterPasswordControl = component.formGroup!.get("masterPassword"); - expect(masterPasswordControl).toBeTruthy(); - - masterPasswordControl!.setValue("test-password"); - fixture.detectChanges(); - - const inputElement = input.nativeElement as HTMLInputElement; - expect(inputElement.value).toEqual("test-password"); - }); - - it("should validate required master password field", async () => { - const formGroup = component.formGroup; - - // Initially form should be invalid (empty required field) - expect(formGroup?.invalid).toEqual(true); - expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(true); - - // Set a value - formGroup?.get("masterPassword")?.setValue("test-password"); - - expect(formGroup?.invalid).toEqual(false); - expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(false); - }); - - it("should toggle password visibility when toggle button is clicked", async () => { - const toggleButton = form.query(By.css("button[bitPasswordInputToggle]")); - expect(toggleButton).toBeTruthy(); - expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement); - const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement; - const input = form.query(By.css('input[formControlName="masterPassword"]')); - expect(input).toBeTruthy(); - expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); - const inputElement = input.nativeElement as HTMLInputElement; - - // Initially password should be hidden - expect(component.showPassword).toEqual(false); - expect(inputElement.type).toEqual("password"); - - // Click toggle button - toggleButtonElement.click(); - fixture.detectChanges(); - - expect(component.showPassword).toEqual(true); - expect(inputElement.type).toEqual("text"); - - // Click toggle button again - toggleButtonElement.click(); - fixture.detectChanges(); - - expect(component.showPassword).toEqual(false); - expect(inputElement.type).toEqual("password"); - }); - }); - }); - describe("successfulMasterPasswordUnlock", () => { const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; const masterPassword = "test-password"; @@ -519,317 +307,6 @@ describe("LockComponent", () => { } }); - describe("unlockViaMasterPassword", () => { - const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey; - const masterPasswordVerificationResponse: MasterPasswordVerificationResponse = { - masterKey: mockMasterKey, - email: "test-email@example.com", - policyOptions: null, - }; - const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const masterPassword = "test-password"; - - beforeEach(async () => { - mockI18nService.t.mockImplementation((key: string) => { - switch (key) { - case "errorOccurred": - return "Error Occurred"; - case "masterPasswordRequired": - return "Master Password is required"; - case "invalidMasterPassword": - return "Invalid Master Password"; - } - return ""; - }); - - component.buildMasterPasswordForm(); - component.formGroup!.controls.masterPassword.setValue(masterPassword); - component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$); - mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue( - masterPasswordVerificationResponse, - ); - mockMasterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey); - }); - - it("should not unlock and show password invalid toast when master password is empty", async () => { - component.formGroup!.controls.masterPassword.setValue(""); - - await component.unlockViaMasterPassword(); - - expect(mockToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "Error Occurred", - message: "Master Password is required", - }); - expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); - }); - - it("should not unlock when no active account", async () => { - component.activeAccount = null; - - await component.unlockViaMasterPassword(); - - expect(mockToastService.showToast).not.toHaveBeenCalled(); - expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); - }); - - it("should not unlock when no form group", async () => { - component.formGroup = null; - - await component.unlockViaMasterPassword(); - - expect(mockToastService.showToast).not.toHaveBeenCalled(); - expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); - }); - - it("should not unlock when input password verification failed due to invalid password", async () => { - mockUserVerificationService.verifyUserByMasterPassword.mockRejectedValueOnce( - new Error("invalid password"), - ); - - await component.unlockViaMasterPassword(); - - expect(mockToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "Error Occurred", - message: "Invalid Master Password", - }); - expect(mockUserVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith( - { - type: VerificationType.MasterPassword, - secret: masterPassword, - } as MasterPasswordVerification, - userId, - component.activeAccount!.email, - ); - expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); - }); - - it("should not unlock when valid password but user have no user key", async () => { - mockMasterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(null); - - await component.unlockViaMasterPassword(); - - expect(mockToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "Error Occurred", - message: "Invalid Master Password", - }); - expect(mockMasterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( - mockMasterKey, - userId, - ); - expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); - }); - - it("should unlock and set user key and sync when valid password", async () => { - await component.unlockViaMasterPassword(); - - assertUnlocked(); - expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); - }); - - it.each([ - [false, undefined, false], - [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false], - [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true], - [true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false], - [false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true], - ])( - "should unlock and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy set during user verification by master password", - async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => { - jest.spyOn(component as any, "doContinue").mockImplementation(async () => { - await mockBiometricStateService.resetUserPromptCancelled(); - mockMessagingService.send("unlocked"); - - if (masterPasswordPolicyOptions?.enforceOnLogin) { - const passwordStrengthResult = mockPasswordStrengthService.getPasswordStrength( - masterPassword, - component.activeAccount!.email, - ); - const evaluated = mockPolicyService.evaluateMasterPassword( - passwordStrengthResult.score, - masterPassword, - masterPasswordPolicyOptions, - ); - if (!evaluated) { - await mockMasterPasswordService.setForceSetPasswordReason( - ForceSetPasswordReason.WeakMasterPassword, - userId, - ); - } - } - - await mockSyncService.fullSync(false); - await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId); - }); - - mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({ - ...masterPasswordVerificationResponse, - policyOptions: - masterPasswordPolicyOptions != null - ? new MasterPasswordPolicyResponse({ - EnforceOnLogin: masterPasswordPolicyOptions.enforceOnLogin, - }) - : null, - } as MasterPasswordVerificationResponse); - const passwordStrengthResult = { score: 1 } as ZXCVBNResult; - mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult); - mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword); - - await component.unlockViaMasterPassword(); - - assertUnlocked(); - if (masterPasswordPolicyOptions?.enforceOnLogin) { - expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith( - masterPassword, - component.activeAccount!.email, - ); - expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith( - passwordStrengthResult.score, - masterPassword, - masterPasswordPolicyOptions, - ); - } - if (forceSetPassword) { - expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( - ForceSetPasswordReason.WeakMasterPassword, - userId, - ); - } else { - expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled(); - } - }, - ); - - it.each([ - [false, undefined, false], - [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false], - [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true], - [true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false], - [false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true], - ])( - "should unlock and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy loaded from policy service", - async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => { - mockPolicyService.masterPasswordPolicyOptions$.mockReturnValue( - of(masterPasswordPolicyOptions), - ); - const passwordStrengthResult = { score: 1 } as ZXCVBNResult; - mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult); - mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword); - - await component.unlockViaMasterPassword(); - - assertUnlocked(); - expect(mockPolicyService.masterPasswordPolicyOptions$).toHaveBeenCalledWith(userId); - if (masterPasswordPolicyOptions?.enforceOnLogin) { - expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith( - masterPassword, - component.activeAccount!.email, - ); - expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith( - passwordStrengthResult.score, - masterPassword, - masterPasswordPolicyOptions, - ); - } - if (forceSetPassword) { - expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( - ForceSetPasswordReason.WeakMasterPassword, - userId, - ); - } else { - expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled(); - } - }, - ); - - it.each([ - [true, ClientType.Browser], - [false, ClientType.Cli], - [false, ClientType.Desktop], - [false, ClientType.Web], - ])( - "should unlock and navigate by url to previous url = %o when client type = %o and previous url was set", - async (shouldNavigate, clientType) => { - const previousUrl = "/test-url"; - component.clientType = clientType; - mockLockComponentService.getPreviousUrl.mockReturnValue(previousUrl); - - await component.unlockViaMasterPassword(); - - assertUnlocked(); - if (shouldNavigate) { - expect(mockRouter.navigateByUrl).toHaveBeenCalledWith(previousUrl); - } else { - expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); - } - }, - ); - - it.each([ - ["/tabs/current", ClientType.Browser], - [undefined, ClientType.Cli], - ["vault", ClientType.Desktop], - ["vault", ClientType.Web], - ])( - "should unlock and navigate to success url = %o when client type = %o", - async (navigateUrl, clientType) => { - component.clientType = clientType; - mockLockComponentService.getPreviousUrl.mockReturnValue(null); - - jest.spyOn(component as any, "doContinue").mockImplementation(async () => { - await mockBiometricStateService.resetUserPromptCancelled(); - mockMessagingService.send("unlocked"); - await mockSyncService.fullSync(false); - await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId); - await mockRouter.navigate([navigateUrl]); - }); - - await component.unlockViaMasterPassword(); - - assertUnlocked(); - expect(mockRouter.navigate).toHaveBeenCalledWith([navigateUrl]); - }, - ); - - it("should unlock and close browser extension popout on firefox extension", async () => { - component.shouldClosePopout = true; - mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension); - - jest.spyOn(component as any, "doContinue").mockImplementation(async () => { - await mockBiometricStateService.resetUserPromptCancelled(); - mockMessagingService.send("unlocked"); - await mockSyncService.fullSync(false); - await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded( - component.activeAccount!.id, - ); - mockLockComponentService.closeBrowserExtensionPopout(); - }); - - await component.unlockViaMasterPassword(); - - assertUnlocked(); - expect(mockLockComponentService.closeBrowserExtensionPopout).toHaveBeenCalled(); - }); - - function assertUnlocked() { - expect(mockToastService.showToast).not.toHaveBeenCalled(); - expect(mockMasterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( - mockMasterKey, - userId, - ); - expect(mockKeyService.setUserKey).toHaveBeenCalledWith(mockUserKey, userId); - expect(mockDeviceTrustService.trustDeviceIfRequired).toHaveBeenCalledWith(userId); - expect(mockBiometricStateService.resetUserPromptCancelled).toHaveBeenCalled(); - expect(mockMessagingService.send).toHaveBeenCalledWith("unlocked"); - expect(mockSyncService.fullSync).toHaveBeenCalledWith(false); - expect(mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith( - userId, - ); - } - }); - describe("logOut", () => { it("should log out user and redirect to login page when dialog confirmed", async () => { mockDialogService.openSimpleDialog.mockResolvedValue(true); diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index ec7ef822335..03ab6033441 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -10,7 +10,6 @@ import { mergeMap, Subject, switchMap, - take, takeUntil, tap, } from "rxjs"; @@ -20,22 +19,14 @@ import { LogoutService } from "@bitwarden/auth/common"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { - MasterPasswordVerification, - MasterPasswordVerificationResponse, -} from "@bitwarden/common/auth/types/verification"; import { ClientType, DeviceType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -115,10 +106,6 @@ export class LockComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); protected loading = true; - protected unlockWithMasterPasswordUnlockDataFlag$ = this.configService.getFeatureFlag$( - FeatureFlag.UnlockWithMasterPasswordUnlockData, - ); - activeAccount: Account | null = null; clientType?: ClientType; @@ -144,7 +131,6 @@ export class LockComponent implements OnInit, OnDestroy { biometricUnlockBtnText?: string; - // masterPassword = ""; showPassword = false; private enforcedMasterPasswordOptions?: MasterPasswordPolicyOptions = undefined; @@ -164,7 +150,6 @@ export class LockComponent implements OnInit, OnDestroy { constructor( private accountService: AccountService, private pinService: PinServiceAbstraction, - private userVerificationService: UserVerificationService, private keyService: KeyService, private platformUtilsService: PlatformUtilsService, private router: Router, @@ -189,7 +174,6 @@ export class LockComponent implements OnInit, OnDestroy { private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, private encryptedMigrator: EncryptedMigrator, - private configService: ConfigService, // desktop deps private broadcasterService: BroadcasterService, ) {} @@ -217,12 +201,20 @@ export class LockComponent implements OnInit, OnDestroy { .pipe( mergeMap(async () => { if (this.activeAccount?.id != null) { + const prevBiometricsEnabled = this.unlockOptions?.biometrics.enabled; + this.unlockOptions = await firstValueFrom( this.lockComponentService.getAvailableUnlockOptions$(this.activeAccount.id), ); + if (this.activeUnlockOption == null) { this.loading = false; await this.setDefaultActiveUnlockOption(this.unlockOptions); + } else if (!prevBiometricsEnabled && this.unlockOptions?.biometrics.enabled) { + await this.setDefaultActiveUnlockOption(this.unlockOptions); + if (this.activeUnlockOption === UnlockOption.Biometrics) { + await this.handleBiometricsUnlockEnabled(); + } } } }), @@ -238,21 +230,10 @@ export class LockComponent implements OnInit, OnDestroy { .subscribe((activeUnlockOption: UnlockOptionValue | null) => { if (activeUnlockOption === UnlockOption.Pin) { this.buildPinForm(); - } else if (activeUnlockOption === UnlockOption.MasterPassword) { - this.buildMasterPasswordForm(); } }); } - buildMasterPasswordForm() { - this.formGroup = this.formBuilder.group( - { - masterPassword: ["", [Validators.required]], - }, - { updateOn: "submit" }, - ); - } - private buildPinForm() { this.formGroup = this.formBuilder.group( { @@ -398,8 +379,6 @@ export class LockComponent implements OnInit, OnDestroy { if (this.activeUnlockOption === UnlockOption.Pin) { return await this.unlockViaPin(); } - - await this.unlockViaMasterPassword(); }; async logOut() { @@ -481,25 +460,6 @@ export class LockComponent implements OnInit, OnDestroy { } } - //TODO PM-25385 This code isn't used and should be removed when removing the UnlockWithMasterPasswordUnlockData feature flag. - togglePassword() { - this.showPassword = !this.showPassword; - const input = document.getElementById( - this.unlockOptions?.pin.enabled ? "pin" : "masterPassword", - ); - - if (input == null) { - return; - } - - if (this.ngZone.isStable) { - input.focus(); - } else { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - this.ngZone.onStable.pipe(take(1)).subscribe(() => input.focus()); - } - } - private validatePin(): boolean { if (this.formGroup?.invalid) { this.toastService.showToast({ @@ -557,83 +517,6 @@ export class LockComponent implements OnInit, OnDestroy { } } - // TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag. - private validateMasterPassword(): boolean { - if (this.formGroup?.invalid) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("masterPasswordRequired"), - }); - return false; - } - - return true; - } - - // TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag. - async unlockViaMasterPassword() { - if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) { - return; - } - - const masterPassword = this.formGroup.controls.masterPassword.value; - - const verification = { - type: VerificationType.MasterPassword, - secret: masterPassword, - } as MasterPasswordVerification; - - let passwordValid = false; - let masterPasswordVerificationResponse: MasterPasswordVerificationResponse | null = null; - try { - masterPasswordVerificationResponse = - await this.userVerificationService.verifyUserByMasterPassword( - verification, - this.activeAccount.id, - this.activeAccount.email, - ); - - if (masterPasswordVerificationResponse?.policyOptions != null) { - this.enforcedMasterPasswordOptions = MasterPasswordPolicyOptions.fromResponse( - masterPasswordVerificationResponse.policyOptions, - ); - } else { - this.enforcedMasterPasswordOptions = undefined; - } - - passwordValid = true; - } catch (e) { - this.logService.error(e); - } - - if (!passwordValid) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("invalidMasterPassword"), - }); - return; - } - - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( - masterPasswordVerificationResponse!.masterKey, - this.activeAccount.id, - ); - if (userKey == null) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("invalidMasterPassword"), - }); - return; - } - - await this.setUserKeyAndContinue(userKey, { - passwordEvaluation: { masterPassword }, - }); - } - async successfulMasterPasswordUnlock(event: { userKey: UserKey; masterPassword: string; diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html index 185fb0666c4..4c7cdd48353 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html @@ -11,7 +11,13 @@ required appInputVerbatim /> - +
diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts index 71287e7684c..dabab3e558a 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts @@ -6,10 +6,12 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ClientType } from "@bitwarden/client-type"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserKey } from "@bitwarden/common/types/key"; @@ -21,6 +23,7 @@ import { ToastService, } from "@bitwarden/components"; import { BiometricsStatus } from "@bitwarden/key-management"; +import { CommandDefinition, MessageListener } from "@bitwarden/messaging"; import { UserId } from "@bitwarden/user-core"; import { UnlockOption, UnlockOptions } from "../../services/lock-component.service"; @@ -36,6 +39,8 @@ describe("MasterPasswordLockComponent", () => { const i18nService = mock(); const toastService = mock(); const logService = mock(); + const platformUtilsService = mock(); + const messageListener = mock(); const mockMasterPassword = "testExample"; const activeAccount: Account = { @@ -103,6 +108,8 @@ describe("MasterPasswordLockComponent", () => { { provide: I18nService, useValue: i18nService }, { provide: ToastService, useValue: toastService }, { provide: LogService, useValue: logService }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + { provide: MessageListener, useValue: messageListener }, ], }).compileComponents(); @@ -281,6 +288,29 @@ describe("MasterPasswordLockComponent", () => { }); }); + describe("ngOnInit", () => { + test.each([ClientType.Browser, ClientType.Web])( + "does nothing when client type is %s", + async (clientType) => { + platformUtilsService.getClientType.mockReturnValue(clientType); + messageListener.messages$.mockReturnValue(of({})); + + await component.ngOnInit(); + + expect(messageListener.messages$).not.toHaveBeenCalled(); + }, + ); + + it("subscribes to windowHidden messages when client type is Desktop", async () => { + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + messageListener.messages$.mockReturnValue(of({})); + + await component.ngOnInit(); + + expect(messageListener.messages$).toHaveBeenCalledWith(new CommandDefinition("windowHidden")); + }); + }); + describe("logout", () => { it("emits logOut event when logout button is clicked", () => { const setup = setupComponent(); diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts index ff1e7f53e5f..1237869717f 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts @@ -1,12 +1,23 @@ -import { Component, computed, inject, input, model, output } from "@angular/core"; +import { + Component, + computed, + inject, + input, + model, + OnDestroy, + OnInit, + output, +} from "@angular/core"; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ClientType } from "@bitwarden/client-type"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { UserKey } from "@bitwarden/common/types/key"; import { AsyncActionsModule, @@ -17,6 +28,7 @@ import { } from "@bitwarden/components"; import { BiometricsStatus } from "@bitwarden/key-management"; import { LogService } from "@bitwarden/logging"; +import { CommandDefinition, MessageListener } from "@bitwarden/messaging"; import { UserId } from "@bitwarden/user-core"; import { @@ -39,12 +51,14 @@ import { IconButtonModule, ], }) -export class MasterPasswordLockComponent { +export class MasterPasswordLockComponent implements OnInit, OnDestroy { private readonly accountService = inject(AccountService); private readonly masterPasswordUnlockService = inject(MasterPasswordUnlockService); private readonly i18nService = inject(I18nService); private readonly toastService = inject(ToastService); private readonly logService = inject(LogService); + private readonly platformUtilsService = inject(PlatformUtilsService); + private readonly messageListener = inject(MessageListener); UnlockOption = UnlockOption; readonly activeUnlockOption = model.required(); @@ -64,6 +78,9 @@ export class MasterPasswordLockComponent { successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>(); logOut = output(); + protected showPassword = false; + private destroy$ = new Subject(); + formGroup = new FormGroup({ masterPassword: new FormControl("", { validators: [Validators.required], @@ -71,6 +88,22 @@ export class MasterPasswordLockComponent { }), }); + async ngOnInit(): Promise { + if (this.platformUtilsService.getClientType() === ClientType.Desktop) { + this.messageListener + .messages$(new CommandDefinition("windowHidden")) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.showPassword = false; + }); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + submit = async () => { this.formGroup.markAllAsTouched(); const masterPassword = this.formGroup.controls.masterPassword.value; diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index feb4a38ac27..6cf44544422 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -129,11 +129,11 @@ export abstract class KeyService { /** * Generates a new user key * @deprecated Interacting with the master key directly is prohibited. Use {@link makeUserKeyV1} instead. - * @throws Error when master key is null and there is no active user - * @param masterKey The user's master key. When null, grabs master key from active user. + * @throws Error when master key is null or undefined. + * @param masterKey The user's master key. * @returns A new user key and the master key protected version of it */ - abstract makeUserKey(masterKey: MasterKey | null): Promise<[UserKey, EncString]>; + abstract makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]>; /** * Generates a new user key for a V1 user * Note: This will be replaced by a higher level function to initialize a whole users cryptographic state in the near future. diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index c0af62fe6e9..c0a0ab62347 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -177,6 +177,32 @@ describe("keyService", () => { }); }); + describe("makeUserKey", () => { + test.each([null as unknown as MasterKey, undefined as unknown as MasterKey])( + "throws when the provided masterKey is %s", + async (masterKey) => { + await expect(keyService.makeUserKey(masterKey)).rejects.toThrow("MasterKey is required"); + }, + ); + + it("encrypts the user key with the master key", async () => { + const mockUserKey = makeSymmetricCryptoKey(64); + const mockEncryptedUserKey = makeEncString("encryptedUserKey"); + + keyGenerationService.createKey.mockResolvedValue(mockUserKey); + encryptService.wrapSymmetricKey.mockResolvedValue(mockEncryptedUserKey); + const stretchedMasterKey = new SymmetricCryptoKey(new Uint8Array(64)); + keyGenerationService.stretchKey.mockResolvedValue(stretchedMasterKey); + + const result = await keyService.makeUserKey(makeSymmetricCryptoKey(32)); + + expect(encryptService.wrapSymmetricKey).toHaveBeenCalledWith(mockUserKey, stretchedMasterKey); + expect(keyGenerationService.createKey).toHaveBeenCalledWith(512); + expect(result[0]).toBe(mockUserKey); + expect(result[1]).toBe(mockEncryptedUserKey); + }); + }); + describe("everHadUserKey$", () => { let everHadUserKeyState: FakeSingleUserState; diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 621a8135d1e..8cb072a4c2a 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -204,17 +204,9 @@ export class DefaultKeyService implements KeyServiceAbstraction { return (await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId))) != null; } - async makeUserKey(masterKey: MasterKey | null): Promise<[UserKey, EncString]> { - if (masterKey == null) { - const userId = await firstValueFrom(this.stateProvider.activeUserId$); - if (userId == null) { - throw new Error("No active user id found."); - } - - masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); - } - if (masterKey == null) { - throw new Error("No Master Key found."); + async makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]> { + if (!masterKey) { + throw new Error("MasterKey is required"); } const newUserKey = await this.keyGenerationService.createKey(512); diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index 85695ea1395..e2fe7d80dc0 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -1,21 +1,23 @@ -@let passwordManager = this.passwordManager(); -@let additionalStorage = this.additionalStorage(); -@let secretsManager = this.secretsManager(); -@let additionalServiceAccounts = this.secretsManager()?.additionalServiceAccounts; +@let cart = this.cart(); +@let term = this.term();
-

- {{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD -

-   - / {{ passwordManager.cadence | i18n }} + @if (this.header(); as header) { + + } @else { +

+ {{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD +

+   + / {{ term }} + }
+ +
+ diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx new file mode 100644 index 00000000000..4519d19a530 --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx @@ -0,0 +1,159 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks"; +import * as AdditionalOptionsCardStories from "./additional-options-card.component.stories"; + + + +# Additional Options Card + +A UI component for displaying additional subscription management options with action buttons for +downloading license and canceling subscription. The component provides quick access to important +subscription actions. + + + +## Table of Contents + +- [Usage](#usage) +- [API](#api) + - [Inputs](#inputs) + - [Outputs](#outputs) +- [Design](#design) +- [Examples](#examples) + - [Default](#default) + - [Actions Disabled](#actions-disabled) +- [Features](#features) +- [Do's and Don'ts](#dos-and-donts) +- [Accessibility](#accessibility) + +## Usage + +The additional options card component displays important subscription management actions on billing +pages and subscription dashboards. It provides quick access to download license and cancel +subscription actions. + +```ts +import { AdditionalOptionsCardComponent } from "@bitwarden/subscription"; +``` + +```html + + +``` + +## API + +### Inputs + +| Input | Type | Description | +| ----------------------- | --------- | ---------------------------------------------------------------------- | +| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | + +### Outputs + +| Output | Type | Description | +| --------------------- | ----------------------------- | ------------------------------------------- | +| `callToActionClicked` | `AdditionalOptionsCardAction` | Emitted when a user clicks an action button | + +**AdditionalOptionsCardAction Type:** + +```typescript +type AdditionalOptionsCardAction = "download-license" | "cancel-subscription"; +``` + +## Design + +The component follows the Bitwarden design system with: + +- **Simple Card Layout**: Clean card design with title and description +- **Action Buttons**: Two prominent buttons for key subscription actions +- **Modern Angular**: Standalone component with signal-based outputs +- **OnPush Change Detection**: Optimized performance +- **Typography**: Uses `bitTypography` directives for consistent text styling +- **Tailwind CSS**: Uses `tw-` prefixed utility classes for styling +- **Button Variants**: Secondary button for download, danger button for cancel +- **Internationalization**: All text uses i18n service for translation support + +## Examples + +### Default + +Standard display with download license and cancel subscription buttons: + + + +```html + + +``` + +**Handler example:** + +```typescript +handleAction(action: AdditionalOptionsCardAction) { + switch (action) { + case "download-license": + // Handle license download + break; + case "cancel-subscription": + // Handle subscription cancellation + break; + } +} +``` + +### Actions Disabled + +Component with action buttons disabled (useful during async operations): + + + +```html + + +``` + +**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like +downloading the license or processing subscription cancellation. + +## Features + +- **Download License**: Provides quick access to download subscription license +- **Cancel Subscription**: Provides quick access to cancel subscription with danger styling +- **Event Emission**: Emits typed events for handling user actions +- **Internationalization**: All text uses i18n service for translation support +- **Type Safety**: Strong TypeScript typing for action events +- **Accessible**: Proper button semantics and keyboard navigation + +## Do's and Don'ts + +### ✅ Do + +- Handle both `download-license` and `cancel-subscription` events in parent components +- Show appropriate confirmation dialogs before executing destructive actions (cancel subscription) +- Disable buttons or show loading states during async operations +- Provide clear user feedback after action completion +- Consider adding additional safety measures for subscription cancellation + +### ❌ Don't + +- Ignore the `callToActionClicked` events - they require handling +- Execute subscription cancellation without user confirmation +- Display this component to users who don't have permission to perform these actions +- Allow multiple simultaneous action executions +- Forget to handle error cases when actions fail + +## Accessibility + +The component includes: + +- **Semantic HTML**: Proper heading hierarchy with `

` and `

` tags +- **Button Accessibility**: Proper `type="button"` attributes on all buttons +- **Button Variants**: Clear visual distinction between secondary and danger actions +- **Keyboard Navigation**: All buttons are keyboard accessible with tab navigation +- **Focus Management**: Clear focus indicators on interactive elements +- **Screen Reader Support**: Descriptive button text for all actions +- **Color Differentiation**: Danger button uses red color to indicate destructive action +- **ARIA Compliance**: Uses semantic HTML reducing need for explicit ARIA attributes diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts new file mode 100644 index 00000000000..345de037fd3 --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts @@ -0,0 +1,116 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { AdditionalOptionsCardComponent } from "@bitwarden/subscription"; + +describe("AdditionalOptionsCardComponent", () => { + let component: AdditionalOptionsCardComponent; + let fixture: ComponentFixture; + let i18nService: jest.Mocked; + + beforeEach(async () => { + i18nService = { + t: jest.fn((key: string) => { + const translations: Record = { + additionalOptions: "Additional options", + additionalOptionsDesc: + "For additional help in managing your subscription, please contact Customer Support.", + downloadLicense: "Download license", + cancelSubscription: "Cancel subscription", + }; + return translations[key] || key; + }), + } as any; + + await TestBed.configureTestingModule({ + imports: [AdditionalOptionsCardComponent], + providers: [{ provide: I18nService, useValue: i18nService }], + }).compileComponents(); + + fixture = TestBed.createComponent(AdditionalOptionsCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("rendering", () => { + it("should display the title", () => { + const title = fixture.debugElement.query(By.css("h3")); + expect(title.nativeElement.textContent.trim()).toBe("Additional options"); + }); + + it("should display the description", () => { + const description = fixture.debugElement.query(By.css("p")); + expect(description.nativeElement.textContent.trim()).toContain( + "For additional help in managing your subscription", + ); + }); + + it("should render both action buttons", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons.length).toBe(2); + }); + + it("should render download license button with correct text", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Download license"); + }); + + it("should render cancel subscription button with correct text", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[1].nativeElement.textContent.trim()).toBe("Cancel subscription"); + }); + }); + + describe("callsToActionDisabled", () => { + it("should disable both buttons when callsToActionDisabled is true", () => { + fixture.componentRef.setInput("callsToActionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should enable both buttons when callsToActionDisabled is false", () => { + fixture.componentRef.setInput("callsToActionDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + + it("should enable both buttons by default", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + }); + + describe("button click events", () => { + it("should emit download-license action when download button is clicked", () => { + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + buttons[0].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("download-license"); + }); + + it("should emit cancel-subscription action when cancel button is clicked", () => { + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + buttons[1].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("cancel-subscription"); + }); + }); +}); diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts new file mode 100644 index 00000000000..66c151f536f --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts @@ -0,0 +1,49 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { AdditionalOptionsCardComponent } from "./additional-options-card.component"; + +export default { + title: "Billing/Additional Options Card", + component: AdditionalOptionsCardComponent, + description: + "Displays additional subscription management options with action buttons for downloading license and canceling subscription.", + decorators: [ + moduleMetadata({ + imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string) => { + const translations: Record = { + additionalOptions: "Additional options", + additionalOptionsDesc: + "For additional help in managing your subscription, please contact Customer Support.", + downloadLicense: "Download license", + cancelSubscription: "Cancel subscription", + }; + return translations[key] || key; + }, + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const ActionsDisabled: Story = { + name: "Actions Disabled", + args: { + callsToActionDisabled: true, + }, +}; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts new file mode 100644 index 00000000000..a962a167ec6 --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts @@ -0,0 +1,17 @@ +import { Component, ChangeDetectionStrategy, output, input } from "@angular/core"; + +import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +export type AdditionalOptionsCardAction = "download-license" | "cancel-subscription"; + +@Component({ + selector: "billing-additional-options-card", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./additional-options-card.component.html", + imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe], +}) +export class AdditionalOptionsCardComponent { + readonly callsToActionDisabled = input(false); + readonly callToActionClicked = output(); +} diff --git a/libs/subscription/src/components/storage-card/storage-card.component.html b/libs/subscription/src/components/storage-card/storage-card.component.html new file mode 100644 index 00000000000..c11f1917176 --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.html @@ -0,0 +1,39 @@ + + +

+

{{ title() }}

+

{{ description() }}

+
+ + +
+ +
+ + +
+ + +
+ diff --git a/libs/subscription/src/components/storage-card/storage-card.component.mdx b/libs/subscription/src/components/storage-card/storage-card.component.mdx new file mode 100644 index 00000000000..43215cb863c --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.mdx @@ -0,0 +1,333 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks"; +import * as StorageCardStories from "./storage-card.component.stories"; + + + +# Storage Card + +A visual component for displaying encrypted file storage usage with a progress bar and action +buttons. The component dynamically adapts its appearance based on storage capacity (empty, used, or +full). + + + +## Table of Contents + +- [Usage](#usage) +- [API](#api) + - [Inputs](#inputs) + - [Outputs](#outputs) +- [Data Structure](#data-structure) +- [Storage States](#storage-states) +- [Design](#design) +- [Examples](#examples) + - [Empty](#empty) + - [Used](#used) + - [Full](#full) + - [Low Usage (10%)](#low-usage-10) + - [Medium Usage (75%)](#medium-usage-75) + - [Nearly Full (95%)](#nearly-full-95) + - [Large Storage Pool (1TB)](#large-storage-pool-1tb) + - [Small Storage Pool (1GB)](#small-storage-pool-1gb) + - [Actions Disabled](#actions-disabled) +- [Features](#features) +- [Do's and Don'ts](#dos-and-donts) +- [Accessibility](#accessibility) + +## Usage + +The storage card component displays storage usage information on billing pages, account management +interfaces, and subscription dashboards. It provides visual feedback through a progress bar and +action buttons for managing storage. + +```ts +import { StorageCardComponent, Storage } from "@bitwarden/subscription"; +``` + +```html + + +``` + +## API + +### Inputs + +| Input | Type | Description | +| ----------------------- | --------- | ---------------------------------------------------------------------- | +| `storage` | `Storage` | **Required.** Storage data including available, used, and readable | +| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. | + +### Outputs + +| Output | Type | Description | +| --------------------- | ------------------- | ------------------------------------------------------- | +| `callToActionClicked` | `StorageCardAction` | Emitted when a user clicks add or remove storage button | + +**StorageCardAction Type:** + +```typescript +type StorageCardAction = "add-storage" | "remove-storage"; +``` + +## Data Structure + +The component uses the `Storage` type: + +```typescript +type Storage = { + available: number; // Total GB available + used: number; // GB used + readableUsed: string; // Formatted string (e.g., "2.5 GB") +}; +``` + +## Storage States + +The component automatically adapts its appearance based on storage usage: + +- **Empty**: 0% used - Gray progress bar, "Storage" title, empty description +- **Used**: 1-99% used - Blue progress bar, "Storage" title, used description +- **Full**: 100% used - Red progress bar, "Storage full" title, full description with warning + +Key behaviors: + +- Progress bar color changes from blue (primary) to red (danger) when full +- Remove storage button is disabled when storage is full +- Title changes to "Storage full" when at capacity +- Description provides context-specific messaging + +## Design + +The component follows the Bitwarden design system with: + +- **Visual Progress Bar**: Animated bar showing storage usage percentage +- **Responsive Colors**: Blue for normal usage, red for full capacity +- **Action Buttons**: Secondary button style for add/remove actions +- **Modern Angular**: Uses signal inputs (`input.required`) and `computed` signals +- **OnPush Change Detection**: Optimized performance +- **Typography**: Uses `bitTypography` directives for consistent text styling +- **Tailwind CSS**: Uses `tw-` prefixed utility classes for styling +- **Card Layout**: Wrapped in `bit-card` component with consistent spacing + +## Examples + +### Empty + +Storage with no files uploaded: + + + +```html + + +``` + +### Used + +Storage with partial usage (50%): + + + +```html + + +``` + +### Full + +Storage at full capacity with disabled remove button: + + + +```html + + +``` + +**Note:** When storage is full, the "Remove storage" button is disabled and the progress bar turns +red. + +### Low Usage (10%) + +Minimal storage usage: + + + +```html + + +``` + +### Medium Usage (75%) + +Substantial storage usage: + + + +```html + + +``` + +### Nearly Full (95%) + +Storage approaching capacity: + + + +```html + + +``` + +### Large Storage Pool (1TB) + +Enterprise-level storage allocation: + + + +```html + + +``` + +### Small Storage Pool (1GB) + +Minimal storage allocation: + + + +```html + + +``` + +### Actions Disabled + +Storage card with action buttons disabled (useful during async operations): + + + +```html + + +``` + +**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like +adding or removing storage. + +## Features + +- **Visual Progress Bar**: Animated progress indicator showing storage usage percentage +- **Dynamic Colors**: Blue (primary) for normal usage, red (danger) when full +- **Context-Aware Titles**: Changes from "Storage" to "Storage full" at capacity +- **Descriptive Messages**: Clear descriptions of current storage status +- **Action Buttons**: Add and remove storage with appropriate enabled/disabled states +- **Automatic Calculations**: Percentage computed from available and used values +- **Responsive Design**: Adapts to container width with flexible layout +- **Computed Signals**: Efficient reactive computations using Angular signals +- **Type Safety**: Strong TypeScript typing for storage data +- **Internationalization**: All text uses i18n service for translation support +- **Event Emission**: Typed events for handling user actions + +## Do's and Don'ts + +### ✅ Do + +- Handle both `add-storage` and `remove-storage` events in parent components +- Provide accurate storage data with `available`, `used`, and `readableUsed` fields +- Use human-readable format strings (e.g., "2.5 GB", "500 MB") for `readableUsed` +- Keep `used` value less than or equal to `available` under normal circumstances +- Update storage data in real-time when user adds or removes storage +- Disable UI interactions when storage operations are in progress +- Show loading states during async storage operations + +### ❌ Don't + +- Omit the `readableUsed` field - it's required for display +- Use inconsistent units between `available` and `used` (both should be in GB) +- Allow negative values for storage amounts +- Ignore the `callToActionClicked` events - they require handling +- Display inaccurate or stale storage information +- Override progress bar colors without considering accessibility +- Show progress percentages greater than 100% +- Use this component for non-storage related progress indicators + +## Accessibility + +The component includes: + +- **Semantic HTML**: Proper heading hierarchy with `

` and `

` tags +- **Button Accessibility**: Proper `type="button"` attributes on all buttons +- **Disabled State**: Visual and functional disabled state for remove button when full +- **Color Contrast**: Sufficient contrast ratios for text and progress bar colors +- **Keyboard Navigation**: All buttons are keyboard accessible with tab navigation +- **Focus Management**: Clear focus indicators on interactive elements +- **Screen Reader Support**: Descriptive text for all storage states and actions +- **ARIA Compliance**: Uses semantic HTML reducing need for explicit ARIA attributes +- **Visual Feedback**: Multiple indicators of state (color, text, disabled buttons) diff --git a/libs/subscription/src/components/storage-card/storage-card.component.spec.ts b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts new file mode 100644 index 00000000000..ae0d7ad9dcb --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts @@ -0,0 +1,285 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Storage, StorageCardComponent } from "@bitwarden/subscription"; + +describe("StorageCardComponent", () => { + let component: StorageCardComponent; + let fixture: ComponentFixture; + let i18nService: jest.Mocked; + + const baseStorage: Storage = { + available: 5, + used: 0, + readableUsed: "0 GB", + }; + + beforeEach(async () => { + i18nService = { + t: jest.fn((key: string, ...args: any[]) => { + const translations: Record = { + storage: "Storage", + storageFull: "Storage full", + storageUsedDescription: `You have used ${args[0]} out of ${args[1]} GB of your encrypted file storage.`, + storageFullDescription: `You have used all ${args[0]} GB of your encrypted storage. To continue storing files, add more storage.`, + addStorage: "Add storage", + removeStorage: "Remove storage", + }; + return translations[key] || key; + }), + } as any; + + await TestBed.configureTestingModule({ + imports: [StorageCardComponent], + providers: [{ provide: I18nService, useValue: i18nService }], + }).compileComponents(); + + fixture = TestBed.createComponent(StorageCardComponent); + component = fixture.componentInstance; + }); + + function setupComponent(storage: Storage) { + fixture.componentRef.setInput("storage", storage); + fixture.detectChanges(); + } + + it("should create", () => { + setupComponent(baseStorage); + expect(component).toBeTruthy(); + }); + + describe("isEmpty", () => { + it("should return true when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.isEmpty()).toBe(true); + }); + + it("should return false when storage is used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.isEmpty()).toBe(false); + }); + + it("should return false when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.isEmpty()).toBe(false); + }); + }); + + describe("isFull", () => { + it("should return false when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.isFull()).toBe(false); + }); + + it("should return false when storage is partially used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.isFull()).toBe(false); + }); + + it("should return true when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.isFull()).toBe(true); + }); + + it("should return true when used exceeds available", () => { + setupComponent({ ...baseStorage, used: 6, readableUsed: "6 GB" }); + expect(component.isFull()).toBe(true); + }); + }); + + describe("percentageUsed", () => { + it("should return 0 when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.percentageUsed()).toBe(0); + }); + + it("should return 50 when half of storage is used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.percentageUsed()).toBe(50); + }); + + it("should return 100 when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.percentageUsed()).toBe(100); + }); + + it("should cap at 100 when used exceeds available", () => { + setupComponent({ ...baseStorage, used: 6, readableUsed: "6 GB" }); + expect(component.percentageUsed()).toBe(100); + }); + + it("should return 0 when available is 0", () => { + setupComponent({ available: 0, used: 0, readableUsed: "0 GB" }); + expect(component.percentageUsed()).toBe(0); + }); + }); + + describe("title", () => { + it("should display 'Storage' when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.title()).toBe("Storage"); + }); + + it("should display 'Storage' when storage is partially used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.title()).toBe("Storage"); + }); + + it("should display 'Storage full' when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.title()).toBe("Storage full"); + }); + }); + + describe("description", () => { + it("should display used description when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + expect(component.description()).toContain("You have used 0 GB out of 5 GB"); + }); + + it("should display used description when storage is partially used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.description()).toContain("You have used 2.5 GB out of 5 GB"); + }); + + it("should display full description when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + const desc = component.description(); + expect(desc).toContain("You have used all 5 GB"); + expect(desc).toContain("To continue storing files, add more storage"); + }); + }); + + describe("progressBarColor", () => { + it("should return primary color when storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.progressBarColor()).toBe("primary"); + }); + + it("should return danger color when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.progressBarColor()).toBe("danger"); + }); + }); + + describe("canRemoveStorage", () => { + it("should return true when storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.canRemoveStorage()).toBe(true); + }); + + it("should return false when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.canRemoveStorage()).toBe(false); + }); + }); + + describe("button rendering", () => { + it("should render both buttons", () => { + setupComponent(baseStorage); + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons.length).toBe(2); + }); + + it("should enable remove button when storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + const buttons = fixture.debugElement.queryAll(By.css("button")); + const removeButton = buttons[1].nativeElement; + expect(removeButton.disabled).toBe(false); + }); + + it("should disable remove button when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + const buttons = fixture.debugElement.queryAll(By.css("button")); + const removeButton = buttons[1]; + expect(removeButton.attributes["aria-disabled"]).toBe("true"); + }); + }); + + describe("callsToActionDisabled", () => { + it("should disable both buttons when callsToActionDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("callsToActionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should enable both buttons when callsToActionDisabled is false and storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + fixture.componentRef.setInput("callsToActionDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + + it("should keep remove button disabled when callsToActionDisabled is false but storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + fixture.componentRef.setInput("callsToActionDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + }); + + describe("button click events", () => { + it("should emit add-storage action when add button is clicked", () => { + setupComponent(baseStorage); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + buttons[0].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("add-storage"); + }); + + it("should emit remove-storage action when remove button is clicked", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + buttons[1].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("remove-storage"); + }); + }); + + describe("progress bar rendering", () => { + it("should render bit-progress component when storage is empty", () => { + setupComponent({ ...baseStorage, used: 0 }); + const progressBar = fixture.debugElement.query(By.css("bit-progress")); + expect(progressBar).toBeTruthy(); + }); + + it("should pass correct barWidth to bit-progress when half storage is used", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.percentageUsed()).toBe(50); + }); + + it("should pass correct barWidth to bit-progress when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.percentageUsed()).toBe(100); + }); + + it("should pass primary color to bit-progress when storage is not full", () => { + setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" }); + expect(component.progressBarColor()).toBe("primary"); + }); + + it("should pass danger color to bit-progress when storage is full", () => { + setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" }); + expect(component.progressBarColor()).toBe("danger"); + }); + }); +}); diff --git a/libs/subscription/src/components/storage-card/storage-card.component.stories.ts b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts new file mode 100644 index 00000000000..8c2070e59f9 --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts @@ -0,0 +1,148 @@ +import { CommonModule } from "@angular/common"; +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ButtonModule, + CardComponent, + ProgressModule, + TypographyModule, +} from "@bitwarden/components"; +import { Storage, StorageCardComponent } from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; + +export default { + title: "Billing/Storage Card", + component: StorageCardComponent, + description: + "Displays storage usage with a visual progress bar and action buttons for adding or removing storage.", + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + ButtonModule, + CardComponent, + ProgressModule, + TypographyModule, + I18nPipe, + ], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string, ...args: any[]) => { + const translations: Record = { + storage: "Storage", + storageFull: "Storage full", + storageUsedDescription: `You have used ${args[0]} out of ${args[1]} GB of your encrypted file storage.`, + storageFullDescription: `You have used all ${args[0]} GB of your encrypted storage. To continue storing files, add more storage.`, + addStorage: "Add storage", + removeStorage: "Remove storage", + }; + return translations[key] || key; + }, + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { + storage: { + available: 5, + used: 0, + readableUsed: "0 GB", + } satisfies Storage, + }, +}; + +export const Used: Story = { + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + }, +}; + +export const Full: Story = { + args: { + storage: { + available: 5, + used: 5, + readableUsed: "5 GB", + } satisfies Storage, + }, +}; + +export const LowUsage: Story = { + name: "Low Usage (10%)", + args: { + storage: { + available: 5, + used: 0.5, + readableUsed: "500 MB", + } satisfies Storage, + }, +}; + +export const MediumUsage: Story = { + name: "Medium Usage (75%)", + args: { + storage: { + available: 5, + used: 3.75, + readableUsed: "3.75 GB", + } satisfies Storage, + }, +}; + +export const NearlyFull: Story = { + name: "Nearly Full (95%)", + args: { + storage: { + available: 5, + used: 4.75, + readableUsed: "4.75 GB", + } satisfies Storage, + }, +}; + +export const LargeStorage: Story = { + name: "Large Storage Pool (1TB)", + args: { + storage: { + available: 1000, + used: 734, + readableUsed: "734 GB", + } satisfies Storage, + }, +}; + +export const SmallStorage: Story = { + name: "Small Storage Pool (1GB)", + args: { + storage: { + available: 1, + used: 0.8, + readableUsed: "800 MB", + } satisfies Storage, + }, +}; + +export const ActionsDisabled: Story = { + name: "Actions Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + callsToActionDisabled: true, + }, +}; diff --git a/libs/subscription/src/components/storage-card/storage-card.component.ts b/libs/subscription/src/components/storage-card/storage-card.component.ts new file mode 100644 index 00000000000..988f4a0ec60 --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.ts @@ -0,0 +1,68 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ButtonModule, + CardComponent, + ProgressModule, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { Storage } from "../../types/storage"; + +export type StorageCardAction = "add-storage" | "remove-storage"; + +@Component({ + selector: "billing-storage-card", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./storage-card.component.html", + imports: [CommonModule, ButtonModule, CardComponent, ProgressModule, TypographyModule, I18nPipe], +}) +export class StorageCardComponent { + private i18nService = inject(I18nService); + + readonly storage = input.required(); + + readonly callsToActionDisabled = input(false); + + readonly callToActionClicked = output(); + + readonly isEmpty = computed(() => this.storage().used === 0); + + readonly isFull = computed(() => { + const storage = this.storage(); + return storage.used >= storage.available; + }); + + readonly percentageUsed = computed(() => { + const storage = this.storage(); + if (storage.available === 0) { + return 0; + } + return Math.min((storage.used / storage.available) * 100, 100); + }); + + readonly title = computed(() => { + return this.isFull() ? this.i18nService.t("storageFull") : this.i18nService.t("storage"); + }); + + readonly description = computed(() => { + const storage = this.storage(); + const available = storage.available; + const readableUsed = storage.readableUsed; + + if (this.isFull()) { + return this.i18nService.t("storageFullDescription", available.toString()); + } + + return this.i18nService.t("storageUsedDescription", readableUsed, available.toString()); + }); + + readonly progressBarColor = computed<"danger" | "primary">(() => { + return this.isFull() ? "danger" : "primary"; + }); + + readonly canRemoveStorage = computed(() => !this.isFull()); +} diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.html b/libs/subscription/src/components/subscription-card/subscription-card.component.html new file mode 100644 index 00000000000..524adc8d008 --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.html @@ -0,0 +1,94 @@ + + +

+

{{ title() }}

+ + + + {{ badge().text }} + +
+ + +
+ +
+ + + @if (callout(); as callout) { + +
+

{{ callout.description }}

+ @if (callout.callsToAction) { +
+ @for (cta of callout.callsToAction; track cta.action) { + + } +
+ } +
+
+ } + + + +

+ @let status = subscription().status; + @switch (status) { + @case ("incomplete") { + {{ "yourSubscriptionWillBeSuspendedOn" | i18n }} + {{ suspension() | date: dateFormat }} + } + @case ("incomplete_expired") { + {{ "yourSubscriptionWasSuspendedOn" | i18n }} + {{ suspension() | date: dateFormat }} + } + @case ("trialing") { + @if (cancelAt(); as cancelAt) { + {{ "yourSubscriptionWillBeCanceledOn" | i18n }} + {{ cancelAt | date: dateFormat }} + } @else { + {{ "yourNextChargeIsFor" | i18n }} + {{ total | currency: "USD" : "symbol" }} USD + {{ "dueOn" | i18n }} + {{ nextCharge() | date: dateFormat }} + } + } + @case ("active") { + @if (cancelAt(); as cancelAt) { + {{ "yourSubscriptionWillBeCanceledOn" | i18n }} + {{ cancelAt | date: dateFormat }} + } @else { + {{ "yourNextChargeIsFor" | i18n }} + {{ total | currency: "USD" : "symbol" }} USD + {{ "dueOn" | i18n }} + {{ nextCharge() | date: dateFormat }} + } + } + @case ("past_due") { + {{ "yourSubscriptionWillBeSuspendedOn" | i18n }} + {{ suspension() | date: dateFormat }} + } + @case ("canceled") { + {{ "yourSubscriptionWasCanceledOn" | i18n }} + {{ canceled() | date: dateFormat }} + } + @case ("unpaid") { + {{ "yourSubscriptionWasSuspendedOn" | i18n }} + {{ suspension() | date: dateFormat }} + } + } +

+
diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx new file mode 100644 index 00000000000..0f605f0f05e --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx @@ -0,0 +1,459 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs/blocks"; +import * as SubscriptionCardStories from "./subscription-card.component.stories"; + + + +# Subscription Card + +A comprehensive UI component for displaying subscription status, payment details, and contextual +action prompts based on subscription state. Dynamically adapts its presentation based on the +subscription status (active, trialing, incomplete, past due, canceled, unpaid, etc.). + + + +## Table of Contents + +- [Usage](#usage) +- [API](#api) + - [Inputs](#inputs) + - [Outputs](#outputs) +- [Data Structure](#data-structure) +- [Subscription States](#subscription-states) +- [Design](#design) +- [Examples](#examples) + - [Active](#active) + - [Active With Upgrade](#active-with-upgrade) + - [Trial](#trial) + - [Trial With Upgrade](#trial-with-upgrade) + - [Incomplete Payment](#incomplete-payment) + - [Incomplete Expired](#incomplete-expired) + - [Past Due](#past-due) + - [Pending Cancellation](#pending-cancellation) + - [Unpaid](#unpaid) + - [Canceled](#canceled) + - [Enterprise](#enterprise) +- [Features](#features) +- [Do's and Don'ts](#dos-and-donts) +- [Accessibility](#accessibility) + +## Usage + +The subscription card component is designed to display comprehensive subscription information on +billing pages, account management interfaces, and subscription dashboards. + +```ts +import { SubscriptionCardComponent, BitwardenSubscription } from "@bitwarden/subscription"; +``` + +```html + + +``` + +## API + +### Inputs + +| Input | Type | Description | +| ------------------- | ----------------------- | ----------------------------------------------------------------------- | +| `title` | `string` | **Required.** The title displayed at the top of the card | +| `subscription` | `BitwardenSubscription` | **Required.** The subscription data including status, cart, and storage | +| `showUpgradeButton` | `boolean` | **Optional.** Whether to show the upgrade callout (default: `false`) | + +### Outputs + +| Output | Type | Description | +| --------------------- | ---------------- | ---------------------------------------------------------- | +| `callToActionClicked` | `PlanCardAction` | Emitted when a user clicks an action button in the callout | + +**PlanCardAction Type:** + +```typescript +type PlanCardAction = + | "contact-support" + | "manage-invoices" + | "reinstate-subscription" + | "update-payment" + | "upgrade-plan"; +``` + +## Data Structure + +The component uses the `BitwardenSubscription` type, which is a discriminated union based on status: + +```typescript +type BitwardenSubscription = HasCart & HasStorage & (Suspension | Billable | Canceled); + +type HasCart = { + cart: Cart; // From @bitwarden/pricing +}; + +type HasStorage = { + storage: { + available: number; + readableUsed: string; + used: number; + }; +}; + +type Suspension = { + status: "incomplete" | "incomplete_expired" | "past_due" | "unpaid"; + suspension: Date; + gracePeriod: number; +}; + +type Billable = { + status: "trialing" | "active"; + nextCharge: Date; + cancelAt?: Date; +}; + +type Canceled = { + status: "canceled"; + canceled: Date; +}; +``` + +## Subscription States + +The component dynamically adapts its appearance and calls-to-action based on the subscription +status: + +- **active**: Subscription is active and paid up +- **trialing**: Subscription is in trial period +- **incomplete**: Payment failed, requires action +- **incomplete_expired**: Payment issue expired, subscription suspended +- **past_due**: Payment overdue but within grace period +- **unpaid**: Subscription suspended due to non-payment +- **canceled**: Subscription was canceled + +Each state displays an appropriate badge, callout message, and relevant action buttons. + +## Design + +The component follows the Bitwarden design system with: + +- **Status Badge**: Color-coded badges (success, warning, danger) indicating subscription state +- **Cart Summary**: Integrated cart summary showing pricing details +- **Contextual Callouts**: Warning/info/danger callouts with appropriate actions +- **Modern Angular**: Uses signal inputs (`input.required`, `input`) and `computed` signals +- **OnPush Change Detection**: Optimized performance with change detection strategy +- **Typography**: Consistent text styling using the typography module +- **Tailwind CSS**: Uses `tw-` prefixed utility classes for styling +- **Responsive Layout**: Flexbox-based layout that adapts to container size + +## Examples + +### Active + +Standard active subscription with regular billing: + + + +```html + + +``` + +### Active With Upgrade + +Active subscription with upgrade promotion callout: + + + +```html + + +``` + +### Trial + +Subscription in trial period showing next charge date: + + + +```html + + +``` + +### Trial With Upgrade + +Trial subscription with upgrade option displayed: + + + +```html + + +``` + +### Incomplete Payment + +Payment failed, showing warning with update payment action: + + + +```html + + +``` + +**Actions available:** Update Payment, Contact Support + +### Incomplete Expired + +Payment issue expired, subscription has been suspended: + + + +```html + + +``` + +**Actions available:** Contact Support + +### Past Due + +Payment past due with active grace period: + + + +```html + + +``` + +**Actions available:** Manage Invoices + +### Pending Cancellation + +Active subscription scheduled to be canceled: + + + +```html + + +``` + +**Actions available:** Reinstate Subscription + +### Unpaid + +Subscription suspended due to unpaid invoices: + + + +```html + + +``` + +**Actions available:** Manage Invoices + +### Canceled + +Subscription that has been canceled: + + + +```html + + +``` + +**Note:** Canceled subscriptions display no callout or actions. + +### Enterprise + +Enterprise subscription with multiple products and discount: + + + +```html + + +``` + +## Features + +- **Dynamic Badge**: Status badge changes color and text based on subscription state +- **Contextual Callouts**: Warning, info, or danger callouts with relevant messages +- **Action Buttons**: Context-specific call-to-action buttons (update payment, contact support, + etc.) +- **Cart Summary Integration**: Embedded cart summary with pricing breakdown +- **Custom Header Support**: Cart summary can display custom headers based on subscription status +- **Date Formatting**: Consistent date formatting throughout (MMM. d, y format) +- **Computed Signals**: Efficient reactive computations using Angular signals +- **Type Safety**: Discriminated union types ensure type-safe subscription data +- **Internationalization**: All text uses i18n service for translation support +- **Event Emission**: Emits typed events for handling user actions + +## Do's and Don'ts + +### ✅ Do + +- Handle all `callToActionClicked` events appropriately in parent components +- Provide complete `BitwardenSubscription` objects with all required fields +- Use the correct subscription status from the defined status types +- Include accurate date information for nextCharge, suspension, and cancelAt fields +- Set `showUpgradeButton` to `true` only when upgrade paths are available +- Use real translation keys that exist in the i18n messages file +- Provide accurate storage information with readable format strings + +### ❌ Don't + +- Omit required fields from the BitwardenSubscription type +- Use custom status strings not defined in the type +- Display upgrade buttons for users who cannot upgrade +- Ignore the `callToActionClicked` events - they require handling +- Mix subscription states (e.g., having both `canceled` date and `nextCharge`) +- Provide incorrect dates that don't match the subscription status +- Override component styles without ensuring accessibility +- Use placeholder or mock data in production environments + +## Accessibility + +The component includes: + +- **Semantic HTML**: Proper heading hierarchy with `

`, `

`, `

` tags +- **ARIA Labels**: Badge variants use appropriate semantic colors +- **Keyboard Navigation**: All action buttons are keyboard accessible +- **Focus Management**: Clear focus indicators on interactive elements +- **Color Contrast**: Sufficient contrast ratios for all text and badge variants +- **Screen Reader Support**: Descriptive text for all interactive elements +- **Button Types**: Proper `type="button"` attributes on all buttons +- **Date Formatting**: Human-readable date formats for assistive technologies diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts new file mode 100644 index 00000000000..3485f2a493a --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts @@ -0,0 +1,704 @@ +import { DatePipe } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Cart } from "@bitwarden/pricing"; +import { BitwardenSubscription, SubscriptionCardComponent } from "@bitwarden/subscription"; + +describe("SubscriptionCardComponent", () => { + let component: SubscriptionCardComponent; + let fixture: ComponentFixture; + + const mockCart: Cart = { + passwordManager: { + seats: { + quantity: 5, + name: "members", + cost: 50, + }, + }, + cadence: "monthly", + estimatedTax: 0, + }; + + const baseSubscription = { + cart: mockCart, + storage: { + available: 1000, + readableUsed: "100 MB", + used: 100, + }, + }; + + const mockI18nService = { + t: (key: string, ...params: any[]) => { + const translations: Record = { + pendingCancellation: "Pending cancellation", + updatePayment: "Update payment", + expired: "Expired", + trial: "Trial", + active: "Active", + pastDue: "Past due", + canceled: "Canceled", + unpaid: "Unpaid", + weCouldNotProcessYourPayment: "We could not process your payment", + contactSupportShort: "Contact support", + yourSubscriptionHasExpired: "Your subscription has expired", + yourSubscriptionIsScheduledToCancel: `Your subscription is scheduled to cancel on ${params[0]}`, + reinstateSubscription: "Reinstate subscription", + upgradeYourPlan: "Upgrade your plan", + premiumShareEvenMore: "Premium share even more", + upgradeNow: "Upgrade now", + youHaveAGracePeriod: `You have a grace period of ${params[0]} days ending ${params[1]}`, + manageInvoices: "Manage invoices", + toReactivateYourSubscription: "To reactivate your subscription", + }; + return translations[key] || key; + }, + }; + + function setupComponent(subscription: BitwardenSubscription, title = "Test Plan") { + fixture.componentRef.setInput("title", title); + fixture.componentRef.setInput("subscription", subscription); + fixture.detectChanges(); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SubscriptionCardComponent], + providers: [ + DatePipe, + { + provide: I18nService, + useValue: mockI18nService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SubscriptionCardComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component).toBeTruthy(); + }); + + describe("Badge rendering", () => { + it("should display 'Update payment' badge with warning variant for incomplete status", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + expect(component.badge().text).toBe("Update payment"); + expect(component.badge().variant).toBe("warning"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge).toBeTruthy(); + expect(badge.nativeElement.textContent.trim()).toBe("Update payment"); + }); + + it("should display 'Expired' badge with danger variant for incomplete_expired status", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete_expired", + suspension: new Date("2025-01-15"), + gracePeriod: 7, + }); + + expect(component.badge().text).toBe("Expired"); + expect(component.badge().variant).toBe("danger"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Expired"); + }); + + it("should display 'Trial' badge with success variant for trialing status", () => { + setupComponent({ + ...baseSubscription, + status: "trialing", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.badge().text).toBe("Trial"); + expect(component.badge().variant).toBe("success"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Trial"); + }); + + it("should display 'Pending cancellation' badge for trialing status with cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "trialing", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + expect(component.badge().text).toBe("Pending cancellation"); + expect(component.badge().variant).toBe("warning"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Pending cancellation"); + }); + + it("should display 'Active' badge with success variant for active status", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.badge().text).toBe("Active"); + expect(component.badge().variant).toBe("success"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Active"); + }); + + it("should display 'Pending cancellation' badge for active status with cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + expect(component.badge().text).toBe("Pending cancellation"); + expect(component.badge().variant).toBe("warning"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Pending cancellation"); + }); + + it("should display 'Past due' badge with warning variant for past_due status", () => { + setupComponent({ + ...baseSubscription, + status: "past_due", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + expect(component.badge().text).toBe("Past due"); + expect(component.badge().variant).toBe("warning"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Past due"); + }); + + it("should display 'Canceled' badge with danger variant for canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: new Date("2025-01-15"), + }); + + expect(component.badge().text).toBe("Canceled"); + expect(component.badge().variant).toBe("danger"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Canceled"); + }); + + it("should display 'Unpaid' badge with danger variant for unpaid status", () => { + setupComponent({ + ...baseSubscription, + status: "unpaid", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + expect(component.badge().text).toBe("Unpaid"); + expect(component.badge().variant).toBe("danger"); + + const badge = fixture.debugElement.query(By.css("[bitBadge]")); + expect(badge.nativeElement.textContent.trim()).toBe("Unpaid"); + }); + }); + + describe("Callout rendering", () => { + it("should display incomplete callout with update payment and contact support actions", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("warning"); + expect(calloutData!.title).toBe("Update payment"); + expect(calloutData!.description).toContain("We could not process your payment"); + expect(calloutData!.callsToAction?.length).toBe(2); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const description = callout.query(By.css("p")); + expect(description.nativeElement.textContent).toContain("We could not process your payment"); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(2); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Update payment"); + expect(buttons[1].nativeElement.textContent.trim()).toBe("Contact support"); + }); + + it("should display incomplete_expired callout with contact support action", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete_expired", + suspension: new Date("2025-01-15"), + gracePeriod: 7, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("danger"); + expect(calloutData!.title).toBe("Expired"); + expect(calloutData!.description).toContain("Your subscription has expired"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const description = callout.query(By.css("p")); + expect(description.nativeElement.textContent).toContain("Your subscription has expired"); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Contact support"); + }); + + it("should display pending cancellation callout for active status with cancelAt", () => { + const cancelDate = new Date("2025-03-01"); + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: cancelDate, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("warning"); + expect(calloutData!.title).toBe("Pending cancellation"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Reinstate subscription"); + }); + + it("should display upgrade callout for active status when showUpgradeButton is true", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + fixture.componentRef.setInput("showUpgradeButton", true); + fixture.detectChanges(); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("info"); + expect(calloutData!.title).toBe("Upgrade your plan"); + expect(calloutData!.description).toContain("Premium share even more"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const description = callout.query(By.css("p")); + expect(description.nativeElement.textContent).toContain("Premium share even more"); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Upgrade now"); + }); + + it("should not display upgrade callout when showUpgradeButton is false", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + fixture.componentRef.setInput("showUpgradeButton", false); + fixture.detectChanges(); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeFalsy(); + }); + + it("should display past_due callout with manage invoices action", () => { + setupComponent({ + ...baseSubscription, + status: "past_due", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("warning"); + expect(calloutData!.title).toBe("Past due"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Manage invoices"); + }); + + it("should not display callout for canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: new Date("2025-01-15"), + }); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeFalsy(); + }); + + it("should display unpaid callout with manage invoices action", () => { + setupComponent({ + ...baseSubscription, + status: "unpaid", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const calloutData = component.callout(); + expect(calloutData).toBeTruthy(); + expect(calloutData!.type).toBe("danger"); + expect(calloutData!.title).toBe("Unpaid"); + expect(calloutData!.description).toContain("To reactivate your subscription"); + expect(calloutData!.callsToAction?.length).toBe(1); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout).toBeTruthy(); + + const description = callout.query(By.css("p")); + expect(description.nativeElement.textContent).toContain("To reactivate your subscription"); + + const buttons = callout.queryAll(By.css("button")); + expect(buttons.length).toBe(1); + expect(buttons[0].nativeElement.textContent.trim()).toBe("Manage invoices"); + }); + }); + + describe("Call-to-action clicks", () => { + it("should emit update-payment action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("bit-callout button")); + expect(buttons.length).toBe(2); + buttons[0].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("update-payment"); + }); + + it("should emit contact-support action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const buttons = fixture.debugElement.queryAll(By.css("bit-callout button")); + buttons[1].triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("contact-support"); + }); + + it("should emit reinstate-subscription action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const button = fixture.debugElement.query(By.css("bit-callout button")); + button.triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("reinstate-subscription"); + }); + + it("should emit upgrade-plan action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + fixture.componentRef.setInput("showUpgradeButton", true); + fixture.detectChanges(); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const button = fixture.debugElement.query(By.css("bit-callout button")); + button.triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("upgrade-plan"); + }); + + it("should emit manage-invoices action when button is clicked", () => { + setupComponent({ + ...baseSubscription, + status: "past_due", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const emitSpy = jest.spyOn(component.callToActionClicked, "emit"); + + const button = fixture.debugElement.query(By.css("bit-callout button")); + button.triggerEventHandler("click", { button: 0 }); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledWith("manage-invoices"); + }); + }); + + describe("Cart summary header content", () => { + it("should display suspension date for incomplete status", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display suspension date for incomplete_expired status", () => { + setupComponent({ + ...baseSubscription, + status: "incomplete_expired", + suspension: new Date("2025-01-15"), + gracePeriod: 7, + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display cancellation date for trialing status with cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "trialing", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display next charge for trialing status without cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "trialing", + nextCharge: new Date("2025-02-01"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display cancellation date for active status with cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: new Date("2025-03-01"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display next charge for active status without cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display suspension date for past_due status", () => { + setupComponent({ + ...baseSubscription, + status: "past_due", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display canceled date for canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: new Date("2025-01-15"), + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + + it("should display suspension date for unpaid status", () => { + setupComponent({ + ...baseSubscription, + status: "unpaid", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + }); + + const cartSummary = fixture.debugElement.query(By.css("billing-cart-summary")); + expect(cartSummary).toBeTruthy(); + }); + }); + + describe("Title rendering", () => { + it("should display the provided title", () => { + setupComponent( + { + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }, + "Premium Plan", + ); + + const title = fixture.debugElement.query(By.css("h2[bitTypography='h3']")); + expect(title.nativeElement.textContent.trim()).toBe("Premium Plan"); + }); + }); + + describe("Computed properties", () => { + it("should compute cancelAt for active status with cancelAt date", () => { + const cancelDate = new Date("2025-03-01"); + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + cancelAt: cancelDate, + }); + + expect(component.cancelAt()).toEqual(cancelDate); + }); + + it("should compute cancelAt as undefined for active status without cancelAt", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.cancelAt()).toBeUndefined(); + }); + + it("should compute canceled date for canceled status", () => { + const canceledDate = new Date("2025-01-15"); + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: canceledDate, + }); + + expect(component.canceled()).toEqual(canceledDate); + }); + + it("should compute canceled as undefined for non-canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.canceled()).toBeUndefined(); + }); + + it("should compute nextCharge for active status", () => { + const nextChargeDate = new Date("2025-02-01"); + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: nextChargeDate, + }); + + expect(component.nextCharge()).toEqual(nextChargeDate); + }); + + it("should compute nextCharge as undefined for canceled status", () => { + setupComponent({ + ...baseSubscription, + status: "canceled", + canceled: new Date("2025-01-15"), + }); + + expect(component.nextCharge()).toBeUndefined(); + }); + + it("should compute suspension date for incomplete status", () => { + const suspensionDate = new Date("2025-02-15"); + setupComponent({ + ...baseSubscription, + status: "incomplete", + suspension: suspensionDate, + gracePeriod: 7, + }); + + expect(component.suspension()).toEqual(suspensionDate); + }); + + it("should compute suspension as undefined for active status", () => { + setupComponent({ + ...baseSubscription, + status: "active", + nextCharge: new Date("2025-02-01"), + }); + + expect(component.suspension()).toBeUndefined(); + }); + }); +}); diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts new file mode 100644 index 00000000000..abe5789382b --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts @@ -0,0 +1,411 @@ +import { CommonModule, DatePipe } from "@angular/common"; +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + BadgeModule, + ButtonModule, + CalloutModule, + CardComponent, + TypographyModule, +} from "@bitwarden/components"; +import { CartSummaryComponent, DiscountTypes } from "@bitwarden/pricing"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { BitwardenSubscription } from "../../types/bitwarden-subscription"; + +import { SubscriptionCardComponent } from "./subscription-card.component"; + +export default { + title: "Billing/Subscription Card", + component: SubscriptionCardComponent, + description: + "Displays subscription status, payment details, and action prompts based on subscription state.", + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + BadgeModule, + ButtonModule, + CalloutModule, + CardComponent, + CartSummaryComponent, + TypographyModule, + I18nPipe, + ], + providers: [ + DatePipe, + { + provide: I18nService, + useValue: { + t: (key: string, ...args: any[]) => { + const translations: Record = { + pendingCancellation: "Pending cancellation", + updatePayment: "Update payment", + expired: "Expired", + trial: "Trial", + active: "Active", + pastDue: "Past due", + canceled: "Canceled", + unpaid: "Unpaid", + weCouldNotProcessYourPayment: + "We could not process your payment. Please update your payment method or contact the support team for assistance.", + contactSupportShort: "Contact Support", + yourSubscriptionHasExpired: + "Your subscription has expired. Please contact the support team for assistance.", + yourSubscriptionIsScheduledToCancel: `Your subscription is scheduled to cancel on ${args[0]}. You can reinstate it anytime before then.`, + reinstateSubscription: "Reinstate subscription", + upgradeYourPlan: "Upgrade your plan", + premiumShareEvenMore: + "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise.", + upgradeNow: "Upgrade now", + youHaveAGracePeriod: `You have a grace period of ${args[0]} days from your subscription expiration date. Please resolve the past due invoices by ${args[1]}.`, + manageInvoices: "Manage invoices", + toReactivateYourSubscription: + "To reactivate your subscription, please resolve the past due invoices.", + yourSubscriptionWillBeSuspendedOn: "Your subscription will be suspended on", + yourSubscriptionWasSuspendedOn: "Your subscription was suspended on", + yourSubscriptionWillBeCanceledOn: "Your subscription will be canceled on", + yourNextChargeIsFor: "Your next charge is for", + dueOn: "due on", + yourSubscriptionWasCanceledOn: "Your subscription was canceled on", + members: "Members", + additionalStorageGB: "Additional storage GB", + month: "month", + year: "year", + estimatedTax: "Estimated tax", + total: "Total", + expandPurchaseDetails: "Expand purchase details", + collapsePurchaseDetails: "Collapse purchase details", + passwordManager: "Password Manager", + secretsManager: "Secrets Manager", + additionalStorageGb: "Additional storage (GB)", + additionalServiceAccountsV2: "Additional machine accounts", + }; + return translations[key] || key; + }, + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Active: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "active", + nextCharge: new Date("2025-02-15"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const ActiveWithUpgrade: Story = { + name: "Active - With Upgrade Option", + args: { + title: "Premium Subscription", + showUpgradeButton: true, + subscription: { + status: "active", + nextCharge: new Date("2025-02-15"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Trial: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "trialing", + nextCharge: new Date("2025-02-01"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 50, + readableUsed: "50 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const TrialWithUpgrade: Story = { + name: "Trial - With Upgrade Option", + args: { + title: "Premium Subscription", + showUpgradeButton: true, + subscription: { + status: "trialing", + nextCharge: new Date("2025-02-01"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 50, + readableUsed: "50 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Incomplete: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "incomplete", + suspension: new Date("2025-02-15"), + gracePeriod: 7, + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const IncompleteExpired: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "incomplete_expired", + suspension: new Date("2025-01-01"), + gracePeriod: 0, + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const PastDue: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "past_due", + suspension: new Date("2025-02-05"), + gracePeriod: 14, + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const PendingCancellation: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "active", + nextCharge: new Date("2025-02-15"), + cancelAt: new Date("2025-03-01"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Unpaid: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "unpaid", + suspension: new Date("2025-01-20"), + gracePeriod: 0, + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Canceled: Story = { + args: { + title: "Premium Subscription", + subscription: { + status: "canceled", + canceled: new Date("2025-01-15"), + cart: { + passwordManager: { + seats: { + quantity: 1, + name: "members", + cost: 10.0, + }, + }, + cadence: "annually", + estimatedTax: 2.71, + }, + storage: { + available: 1000, + used: 234, + readableUsed: "234 MB", + }, + } satisfies BitwardenSubscription, + }, +}; + +export const Enterprise: Story = { + args: { + title: "Enterprise Subscription", + subscription: { + status: "active", + nextCharge: new Date("2025-03-01"), + cart: { + passwordManager: { + seats: { + quantity: 5, + name: "members", + cost: 7, + }, + additionalStorage: { + quantity: 2, + name: "additionalStorageGB", + cost: 0.5, + }, + }, + secretsManager: { + seats: { + quantity: 3, + name: "members", + cost: 13, + }, + additionalServiceAccounts: { + quantity: 5, + name: "additionalServiceAccountsV2", + cost: 1, + }, + }, + discount: { + type: DiscountTypes.PercentOff, + active: true, + value: 0.25, + }, + cadence: "monthly", + estimatedTax: 6.4, + }, + storage: { + available: 7, + readableUsed: "7 GB", + used: 0, + }, + }, + }, +}; diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.ts new file mode 100644 index 00000000000..f52127a0104 --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.ts @@ -0,0 +1,274 @@ +import { CommonModule, DatePipe } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from "@angular/core"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + BadgeModule, + BadgeVariant, + ButtonModule, + CalloutModule, + CardComponent, + TypographyModule, + CalloutTypes, + ButtonType, +} from "@bitwarden/components"; +import { CartSummaryComponent, Maybe } from "@bitwarden/pricing"; +import { BitwardenSubscription, SubscriptionStatuses } from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; + +export type PlanCardAction = + | "contact-support" + | "manage-invoices" + | "reinstate-subscription" + | "update-payment" + | "upgrade-plan"; + +type Badge = { text: string; variant: BadgeVariant }; + +type Callout = Maybe<{ + title: string; + type: CalloutTypes; + icon?: string; + description: string; + callsToAction?: { + text: string; + buttonType: ButtonType; + action: PlanCardAction; + }[]; +}>; + +@Component({ + selector: "billing-subscription-card", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./subscription-card.component.html", + imports: [ + CommonModule, + BadgeModule, + ButtonModule, + CalloutModule, + CardComponent, + CartSummaryComponent, + TypographyModule, + I18nPipe, + ], +}) +export class SubscriptionCardComponent { + private datePipe = inject(DatePipe); + private i18nService = inject(I18nService); + + protected readonly dateFormat = "MMM. d, y"; + + readonly title = input.required(); + + readonly subscription = input.required(); + + readonly showUpgradeButton = input(false); + + readonly callToActionClicked = output(); + + readonly badge = computed(() => { + const subscription = this.subscription(); + const pendingCancellation: Badge = { + text: this.i18nService.t("pendingCancellation"), + variant: "warning", + }; + switch (subscription.status) { + case SubscriptionStatuses.Incomplete: { + return { + text: this.i18nService.t("updatePayment"), + variant: "warning", + }; + } + case SubscriptionStatuses.IncompleteExpired: { + return { + text: this.i18nService.t("expired"), + variant: "danger", + }; + } + case SubscriptionStatuses.Trialing: { + if (subscription.cancelAt) { + return pendingCancellation; + } + return { + text: this.i18nService.t("trial"), + variant: "success", + }; + } + case SubscriptionStatuses.Active: { + if (subscription.cancelAt) { + return pendingCancellation; + } + return { + text: this.i18nService.t("active"), + variant: "success", + }; + } + case SubscriptionStatuses.PastDue: { + return { + text: this.i18nService.t("pastDue"), + variant: "warning", + }; + } + case SubscriptionStatuses.Canceled: { + return { + text: this.i18nService.t("canceled"), + variant: "danger", + }; + } + case SubscriptionStatuses.Unpaid: { + return { + text: this.i18nService.t("unpaid"), + variant: "danger", + }; + } + } + }); + + readonly callout = computed(() => { + const subscription = this.subscription(); + switch (subscription.status) { + case SubscriptionStatuses.Incomplete: { + return { + title: this.i18nService.t("updatePayment"), + type: "warning", + description: this.i18nService.t("weCouldNotProcessYourPayment"), + callsToAction: [ + { + text: this.i18nService.t("updatePayment"), + buttonType: "unstyled", + action: "update-payment", + }, + { + text: this.i18nService.t("contactSupportShort"), + buttonType: "unstyled", + action: "contact-support", + }, + ], + }; + } + case SubscriptionStatuses.IncompleteExpired: { + return { + title: this.i18nService.t("expired"), + type: "danger", + description: this.i18nService.t("yourSubscriptionHasExpired"), + callsToAction: [ + { + text: this.i18nService.t("contactSupportShort"), + buttonType: "unstyled", + action: "contact-support", + }, + ], + }; + } + case SubscriptionStatuses.Trialing: + case SubscriptionStatuses.Active: { + if (subscription.cancelAt) { + const cancelAt = this.datePipe.transform(subscription.cancelAt, this.dateFormat); + return { + title: this.i18nService.t("pendingCancellation"), + type: "warning", + description: this.i18nService.t("yourSubscriptionIsScheduledToCancel", cancelAt!), + callsToAction: [ + { + text: this.i18nService.t("reinstateSubscription"), + buttonType: "unstyled", + action: "reinstate-subscription", + }, + ], + }; + } + if (!this.showUpgradeButton()) { + return null; + } + return { + title: this.i18nService.t("upgradeYourPlan"), + type: "info", + icon: "bwi-gem", + description: this.i18nService.t("premiumShareEvenMore"), + callsToAction: [ + { + text: this.i18nService.t("upgradeNow"), + buttonType: "unstyled", + action: "upgrade-plan", + }, + ], + }; + } + case SubscriptionStatuses.PastDue: { + const suspension = this.datePipe.transform(subscription.suspension, this.dateFormat); + return { + title: this.i18nService.t("pastDue"), + type: "warning", + description: this.i18nService.t( + "youHaveAGracePeriod", + subscription.gracePeriod, + suspension!, + ), + callsToAction: [ + { + text: this.i18nService.t("manageInvoices"), + buttonType: "unstyled", + action: "manage-invoices", + }, + ], + }; + } + case SubscriptionStatuses.Canceled: { + return null; + } + case SubscriptionStatuses.Unpaid: { + return { + title: this.i18nService.t("unpaid"), + type: "danger", + description: this.i18nService.t("toReactivateYourSubscription"), + callsToAction: [ + { + text: this.i18nService.t("manageInvoices"), + buttonType: "unstyled", + action: "manage-invoices", + }, + ], + }; + } + } + }); + + readonly cancelAt = computed>(() => { + const subscription = this.subscription(); + if ( + subscription.status === SubscriptionStatuses.Trialing || + subscription.status === SubscriptionStatuses.Active + ) { + return subscription.cancelAt; + } + }); + + readonly canceled = computed>(() => { + const subscription = this.subscription(); + if (subscription.status === SubscriptionStatuses.Canceled) { + return subscription.canceled; + } + }); + + readonly nextCharge = computed>(() => { + const subscription = this.subscription(); + if ( + subscription.status === SubscriptionStatuses.Trialing || + subscription.status === SubscriptionStatuses.Active + ) { + return subscription.nextCharge; + } + }); + + readonly suspension = computed>(() => { + const subscription = this.subscription(); + if ( + subscription.status === SubscriptionStatuses.Incomplete || + subscription.status === SubscriptionStatuses.IncompleteExpired || + subscription.status === SubscriptionStatuses.PastDue || + subscription.status === SubscriptionStatuses.Unpaid + ) { + return subscription.suspension; + } + }); +} diff --git a/libs/subscription/src/index.ts b/libs/subscription/src/index.ts index 3deb7c89d41..29b96017cda 100644 --- a/libs/subscription/src/index.ts +++ b/libs/subscription/src/index.ts @@ -1 +1,8 @@ -export type Placeholder = unknown; +// Components +export * from "./components/additional-options-card/additional-options-card.component"; +export * from "./components/subscription-card/subscription-card.component"; +export * from "./components/storage-card/storage-card.component"; + +// Types +export * from "./types/bitwarden-subscription"; +export * from "./types/storage"; diff --git a/libs/subscription/src/subscription.spec.ts b/libs/subscription/src/subscription.spec.ts deleted file mode 100644 index 7f0836a5063..00000000000 --- a/libs/subscription/src/subscription.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as lib from "./index"; - -describe("subscription", () => { - // This test will fail until something is exported from index.ts - it("should work", () => { - expect(lib).toBeDefined(); - }); -}); diff --git a/libs/subscription/src/types/bitwarden-subscription.ts b/libs/subscription/src/types/bitwarden-subscription.ts new file mode 100644 index 00000000000..15bf64d03aa --- /dev/null +++ b/libs/subscription/src/types/bitwarden-subscription.ts @@ -0,0 +1,40 @@ +import { Cart } from "@bitwarden/pricing"; + +import { Storage } from "./storage"; + +export const SubscriptionStatuses = { + Incomplete: "incomplete", + IncompleteExpired: "incomplete_expired", + Trialing: "trialing", + Active: "active", + PastDue: "past_due", + Canceled: "canceled", + Unpaid: "unpaid", +} as const; + +type HasCart = { + cart: Cart; +}; + +type HasStorage = { + storage: Storage; +}; + +type Suspension = { + status: "incomplete" | "incomplete_expired" | "past_due" | "unpaid"; + suspension: Date; + gracePeriod: number; +}; + +type Billable = { + status: "trialing" | "active"; + nextCharge: Date; + cancelAt?: Date; +}; + +type Canceled = { + status: "canceled"; + canceled: Date; +}; + +export type BitwardenSubscription = HasCart & HasStorage & (Suspension | Billable | Canceled); diff --git a/libs/subscription/src/types/storage.ts b/libs/subscription/src/types/storage.ts new file mode 100644 index 00000000000..beb187250dd --- /dev/null +++ b/libs/subscription/src/types/storage.ts @@ -0,0 +1,5 @@ +export type Storage = { + available: number; + readableUsed: string; + used: number; +}; diff --git a/libs/subscription/test.setup.ts b/libs/subscription/test.setup.ts new file mode 100644 index 00000000000..159c28d2be5 --- /dev/null +++ b/libs/subscription/test.setup.ts @@ -0,0 +1,28 @@ +import { webcrypto } from "crypto"; +import "@bitwarden/ui-common/setup-jest"; + +Object.defineProperty(window, "CSS", { value: null }); +Object.defineProperty(window, "getComputedStyle", { + value: () => { + return { + display: "none", + appearance: ["-webkit-appearance"], + }; + }, +}); + +Object.defineProperty(document, "doctype", { + value: "", +}); +Object.defineProperty(document.body.style, "transform", { + value: () => { + return { + enumerable: true, + configurable: true, + }; + }, +}); + +Object.defineProperty(window, "crypto", { + value: webcrypto, +}); diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index 232fb40aeb2..058b7db1299 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -18,7 +18,6 @@ import { BehaviorSubject, combineLatest, firstValueFrom, - from, map, merge, Observable, @@ -43,8 +42,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ClientType, EventType } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -100,7 +97,6 @@ import { ExportScopeCalloutComponent } from "./export-scope-callout.component"; }) export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { private _organizationId$ = new BehaviorSubject(undefined); - private createDefaultLocationFlagEnabled$: Observable; private _showExcludeMyItems = false; /** @@ -259,13 +255,11 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { protected organizationService: OrganizationService, private accountService: AccountService, private collectionService: CollectionService, - private configService: ConfigService, private platformUtilsService: PlatformUtilsService, @Optional() private router?: Router, ) {} async ngOnInit() { - this.observeFeatureFlags(); this.observeFormState(); this.observePolicyStatus(); this.observeFormSelections(); @@ -286,12 +280,6 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { this.setupPolicyBasedFormState(); } - private observeFeatureFlags(): void { - this.createDefaultLocationFlagEnabled$ = from( - this.configService.getFeatureFlag(FeatureFlag.CreateDefaultLocation), - ).pipe(shareReplay({ bufferSize: 1, refCount: true })); - } - private observeFormState(): void { this.exportForm.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((c) => { this.formDisabled.emit(c === "DISABLED"); @@ -380,32 +368,24 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { /** * Determine value of showExcludeMyItems. Returns true when: - * CreateDefaultLocation feature flag is on - * AND organizationDataOwnershipPolicy is enabled for the selected organization + * organizationDataOwnershipPolicy is enabled for the selected organization * AND a valid OrganizationId is present (not exporting from individual vault) */ private observeMyItemsExclusionCriteria(): void { combineLatest({ - createDefaultLocationFlagEnabled: this.createDefaultLocationFlagEnabled$, organizationDataOwnershipPolicyEnabledForOrg: this.organizationDataOwnershipPolicyEnabledForOrg$, organizationId: this._organizationId$, }) .pipe(takeUntil(this.destroy$)) - .subscribe( - ({ - createDefaultLocationFlagEnabled, - organizationDataOwnershipPolicyEnabledForOrg, - organizationId, - }) => { - if (!createDefaultLocationFlagEnabled || !organizationId) { - this._showExcludeMyItems = false; - return; - } + .subscribe(({ organizationDataOwnershipPolicyEnabledForOrg, organizationId }) => { + if (!organizationId) { + this._showExcludeMyItems = false; + return; + } - this._showExcludeMyItems = organizationDataOwnershipPolicyEnabledForOrg; - }, - ); + this._showExcludeMyItems = organizationDataOwnershipPolicyEnabledForOrg; + }); } // Setup validator adjustments based on format and encryption type changes diff --git a/libs/tools/send/send-ui/src/index.ts b/libs/tools/send/send-ui/src/index.ts index ac8b9383681..b125e76e000 100644 --- a/libs/tools/send/send-ui/src/index.ts +++ b/libs/tools/send/send-ui/src/index.ts @@ -1,5 +1,6 @@ export * from "./send-form"; export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component"; +export { NewSendDropdownV2Component } from "./new-send-dropdown-v2/new-send-dropdown-v2.component"; export * from "./add-edit/send-add-edit-dialog.component"; export { SendListItemsContainerComponent } from "./send-list-items-container/send-list-items-container.component"; export { SendItemsService } from "./services/send-items.service"; @@ -7,3 +8,4 @@ export { SendSearchComponent } from "./send-search/send-search.component"; export { SendListFiltersComponent } from "./send-list-filters/send-list-filters.component"; export { SendListFiltersService } from "./services/send-list-filters.service"; export { SendTableComponent } from "./send-table/send-table.component"; +export { SendListComponent, SendListState } from "./send-list/send-list.component"; diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.html b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.html new file mode 100644 index 00000000000..7e447a13441 --- /dev/null +++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.html @@ -0,0 +1,19 @@ + + + + + diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts new file mode 100644 index 00000000000..8f8390a170c --- /dev/null +++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts @@ -0,0 +1,261 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; + +import { NewSendDropdownV2Component } from "./new-send-dropdown-v2.component"; + +describe("NewSendDropdownV2Component", () => { + let component: NewSendDropdownV2Component; + let fixture: ComponentFixture; + let billingService: MockProxy; + let accountService: MockProxy; + let premiumUpgradeService: MockProxy; + + beforeEach(async () => { + billingService = mock(); + accountService = mock(); + premiumUpgradeService = mock(); + + // Default: user has premium + accountService.activeAccount$ = of({ id: "user-123" } as any); + billingService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + + const i18nService = mock(); + i18nService.t.mockImplementation((key: string) => key); + + await TestBed.configureTestingModule({ + imports: [NewSendDropdownV2Component], + providers: [ + { provide: BillingAccountProfileStateService, useValue: billingService }, + { provide: AccountService, useValue: accountService }, + { provide: PremiumUpgradePromptService, useValue: premiumUpgradeService }, + { provide: I18nService, useValue: i18nService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(NewSendDropdownV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("input signals", () => { + it("has correct default input values", () => { + expect(component.hideIcon()).toBe(false); + expect(component.buttonType()).toBe("primary"); + }); + + it("accepts input signal values", () => { + fixture.componentRef.setInput("hideIcon", true); + fixture.componentRef.setInput("buttonType", "secondary"); + + expect(component.hideIcon()).toBe(true); + expect(component.buttonType()).toBe("secondary"); + }); + }); + + describe("premium status detection", () => { + it("hasNoPremium is false when user has premium", () => { + billingService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + accountService.activeAccount$ = of({ id: "user-123" } as any); + + fixture = TestBed.createComponent(NewSendDropdownV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component["hasNoPremium"]()).toBe(false); + }); + + it("hasNoPremium is true when user lacks premium", () => { + billingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + accountService.activeAccount$ = of({ id: "user-123" } as any); + + fixture = TestBed.createComponent(NewSendDropdownV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component["hasNoPremium"]()).toBe(true); + }); + + it("hasNoPremium defaults to true when no active account", () => { + accountService.activeAccount$ = of(null); + + fixture = TestBed.createComponent(NewSendDropdownV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component["hasNoPremium"]()).toBe(true); + }); + + it("hasNoPremium updates reactively when premium status changes", async () => { + const premiumSubject = new BehaviorSubject(false); + billingService.hasPremiumFromAnySource$.mockReturnValue(premiumSubject.asObservable()); + + fixture = TestBed.createComponent(NewSendDropdownV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component["hasNoPremium"]()).toBe(true); + + premiumSubject.next(true); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component["hasNoPremium"]()).toBe(false); + }); + }); + + describe("text send functionality", () => { + it("onTextSendClick emits SendType.Text", () => { + const emitSpy = jest.fn(); + component.addSend.subscribe(emitSpy); + + component["onTextSendClick"](); + + expect(emitSpy).toHaveBeenCalledWith(SendType.Text); + }); + + it("allows text send without premium", () => { + billingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + fixture = TestBed.createComponent(NewSendDropdownV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + + const emitSpy = jest.fn(); + component.addSend.subscribe(emitSpy); + + component["onTextSendClick"](); + + expect(emitSpy).toHaveBeenCalledWith(SendType.Text); + expect(premiumUpgradeService.promptForPremium).not.toHaveBeenCalled(); + }); + }); + + describe("file send premium gating", () => { + it("onFileSendClick emits SendType.File when user has premium", async () => { + const emitSpy = jest.fn(); + component.addSend.subscribe(emitSpy); + + await component["onFileSendClick"](); + + expect(emitSpy).toHaveBeenCalledWith(SendType.File); + expect(premiumUpgradeService.promptForPremium).not.toHaveBeenCalled(); + }); + + it("onFileSendClick shows premium prompt without premium", async () => { + billingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + premiumUpgradeService.promptForPremium.mockResolvedValue(); + + fixture = TestBed.createComponent(NewSendDropdownV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + + const emitSpy = jest.fn(); + component.addSend.subscribe(emitSpy); + + await component["onFileSendClick"](); + + expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it("does not emit file send type when premium prompt is shown", async () => { + billingService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + fixture = TestBed.createComponent(NewSendDropdownV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + + const emitSpy = jest.fn(); + component.addSend.subscribe(emitSpy); + + await component["onFileSendClick"](); + + expect(emitSpy).not.toHaveBeenCalledWith(SendType.File); + }); + + it("allows file send after user gains premium", async () => { + const premiumSubject = new BehaviorSubject(false); + billingService.hasPremiumFromAnySource$.mockReturnValue(premiumSubject.asObservable()); + + fixture = TestBed.createComponent(NewSendDropdownV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + + // Initially no premium + let emitSpy = jest.fn(); + component.addSend.subscribe(emitSpy); + await component["onFileSendClick"](); + expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled(); + + // Gain premium + premiumSubject.next(true); + await fixture.whenStable(); + fixture.detectChanges(); + + // Now should emit + emitSpy = jest.fn(); + component.addSend.subscribe(emitSpy); + await component["onFileSendClick"](); + expect(emitSpy).toHaveBeenCalledWith(SendType.File); + }); + }); + + describe("edge cases", () => { + it("handles null account without errors", () => { + accountService.activeAccount$ = of(null); + + expect(() => { + fixture = TestBed.createComponent(NewSendDropdownV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }).not.toThrow(); + + expect(component["hasNoPremium"]()).toBe(true); + }); + + it("handles rapid clicks without race conditions", async () => { + const emitSpy = jest.fn(); + component.addSend.subscribe(emitSpy); + + // Rapid text send clicks + component["onTextSendClick"](); + component["onTextSendClick"](); + component["onTextSendClick"](); + + expect(emitSpy).toHaveBeenCalledTimes(3); + + // Rapid file send clicks (with premium) + await Promise.all([ + component["onFileSendClick"](), + component["onFileSendClick"](), + component["onFileSendClick"](), + ]); + + expect(emitSpy).toHaveBeenCalledTimes(6); // 3 text + 3 file + }); + + it("cleans up subscriptions on destroy", () => { + const subscription = component["hasNoPremium"]; + + fixture.destroy(); + + // Signal should still exist but component cleanup handled by Angular + expect(() => subscription()).not.toThrow(); + }); + }); +}); diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts new file mode 100644 index 00000000000..7e7c4a2005b --- /dev/null +++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, inject, input, output } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { map, of, switchMap } from "rxjs"; + +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; + +// Desktop-specific version of NewSendDropdownComponent. +// Unlike the shared library version, this component emits events instead of using Angular Router, +// which aligns with Desktop's modal-based architecture. +@Component({ + selector: "tools-new-send-dropdown-v2", + templateUrl: "new-send-dropdown-v2.component.html", + imports: [JslibModule, ButtonModule, MenuModule, PremiumBadgeComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NewSendDropdownV2Component { + readonly hideIcon = input(false); + readonly buttonType = input("primary"); + + readonly addSend = output(); + + protected sendType = SendType; + + private readonly billingAccountProfileStateService = inject(BillingAccountProfileStateService); + private readonly accountService = inject(AccountService); + private readonly premiumUpgradePromptService = inject(PremiumUpgradePromptService); + + protected readonly hasNoPremium = toSignal( + this.accountService.activeAccount$.pipe( + switchMap((account) => { + if (!account) { + return of(true); + } + return this.billingAccountProfileStateService + .hasPremiumFromAnySource$(account.id) + .pipe(map((hasPremium) => !hasPremium)); + }), + ), + { initialValue: true }, + ); + + protected onTextSendClick(): void { + this.addSend.emit(SendType.Text); + } + + protected async onFileSendClick(): Promise { + if (this.hasNoPremium()) { + await this.premiumUpgradePromptService.promptForPremium(); + } else { + this.addSend.emit(SendType.File); + } + } +} diff --git a/libs/tools/send/send-ui/src/send-list/send-list.component.html b/libs/tools/send/send-ui/src/send-list/send-list.component.html new file mode 100644 index 00000000000..acdd6fa8d4d --- /dev/null +++ b/libs/tools/send/send-ui/src/send-list/send-list.component.html @@ -0,0 +1,31 @@ +@if (loading()) { + +} @else { + @if (showSearchBar()) { + + + } + + @if (noSearchResults()) { + + + {{ "sendsTitleNoSearchResults" | i18n }} + {{ "sendsBodyNoSearchResults" | i18n }} + + } @else if (listState() === sendListState.NoResults || listState() === sendListState.Empty) { + + + + {{ "sendsTitleNoItems" | i18n }} + {{ "sendsBodyNoItems" | i18n }} + + + } +} diff --git a/libs/tools/send/send-ui/src/send-list/send-list.component.spec.ts b/libs/tools/send/send-ui/src/send-list/send-list.component.spec.ts new file mode 100644 index 00000000000..03539b99afa --- /dev/null +++ b/libs/tools/send/send-ui/src/send-list/send-list.component.spec.ts @@ -0,0 +1,89 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { SendItemsService } from "../services/send-items.service"; + +import { SendListComponent } from "./send-list.component"; + +describe("SendListComponent", () => { + let component: SendListComponent; + let fixture: ComponentFixture; + let i18nService: MockProxy; + let sendItemsService: MockProxy; + + beforeEach(async () => { + i18nService = mock(); + i18nService.t.mockImplementation((key) => key); + + // Mock SendItemsService for SendSearchComponent child component + sendItemsService = mock(); + sendItemsService.latestSearchText$ = of(""); + + await TestBed.configureTestingModule({ + imports: [SendListComponent], + providers: [ + { provide: I18nService, useValue: i18nService }, + { provide: SendItemsService, useValue: sendItemsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SendListComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should display empty state when listState is Empty", () => { + fixture.componentRef.setInput("sends", []); + fixture.componentRef.setInput("listState", "Empty"); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + expect(compiled.textContent).toContain("sendsTitleNoItems"); + }); + + it("should display no results state when listState is NoResults", () => { + fixture.componentRef.setInput("sends", []); + fixture.componentRef.setInput("listState", "NoResults"); + fixture.detectChanges(); + + const compiled = fixture.nativeElement; + // Component shows same empty state for both Empty and NoResults states + expect(compiled.textContent).toContain("sendsTitleNoItems"); + }); + + it("should emit editSend event when send is edited", () => { + const editSpy = jest.fn(); + component.editSend.subscribe(editSpy); + + const mockSend = { id: "test-id", name: "Test Send" } as any; + component["onEditSend"](mockSend); + + expect(editSpy).toHaveBeenCalledWith(mockSend); + }); + + it("should emit copySend event when send link is copied", () => { + const copySpy = jest.fn(); + component.copySend.subscribe(copySpy); + + const mockSend = { id: "test-id", name: "Test Send" } as any; + component["onCopySend"](mockSend); + + expect(copySpy).toHaveBeenCalledWith(mockSend); + }); + + it("should emit deleteSend event when send is deleted", () => { + const deleteSpy = jest.fn(); + component.deleteSend.subscribe(deleteSpy); + + const mockSend = { id: "test-id", name: "Test Send" } as any; + component["onDeleteSend"](mockSend); + + expect(deleteSpy).toHaveBeenCalledWith(mockSend); + }); +}); diff --git a/libs/tools/send/send-ui/src/send-list/send-list.component.ts b/libs/tools/send/send-ui/src/send-list/send-list.component.ts new file mode 100644 index 00000000000..d90f77913aa --- /dev/null +++ b/libs/tools/send/send-ui/src/send-list/send-list.component.ts @@ -0,0 +1,105 @@ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + output, +} from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { NoResults, NoSendsIcon } from "@bitwarden/assets/svg"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { + ButtonModule, + NoItemsModule, + SpinnerComponent, + TableDataSource, +} from "@bitwarden/components"; + +import { SendSearchComponent } from "../send-search/send-search.component"; +import { SendTableComponent } from "../send-table/send-table.component"; + +/** A state of the Send list UI. */ +export const SendListState = Object.freeze({ + /** No Sends exist at all (File or Text). */ + Empty: "Empty", + /** Sends exist, but none match the current Side Nav Filter (File or Text). */ + NoResults: "NoResults", +} as const); + +/** A state of the Send list UI. */ +export type SendListState = (typeof SendListState)[keyof typeof SendListState]; + +/** + * A container component for displaying the Send list with search, table, and empty states. + * Handles the presentation layer while delegating data management to services. + */ +@Component({ + selector: "tools-send-list", + templateUrl: "./send-list.component.html", + imports: [ + CommonModule, + JslibModule, + ButtonModule, + NoItemsModule, + SpinnerComponent, + SendSearchComponent, + SendTableComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendListComponent { + protected readonly noItemIcon = NoSendsIcon; + protected readonly noResultsIcon = NoResults; + protected readonly sendListState = SendListState; + + private i18nService = inject(I18nService); + + readonly sends = input.required(); + readonly loading = input(false); + readonly disableSend = input(false); + readonly listState = input(null); + readonly searchText = input(""); + + protected readonly showSearchBar = computed( + () => this.sends().length > 0 || this.searchText().length > 0, + ); + + protected readonly noSearchResults = computed( + () => this.showSearchBar() && (this.sends().length === 0 || this.searchText().length > 0), + ); + + // Reusable data source instance - updated reactively when sends change + protected readonly dataSource = new TableDataSource(); + + constructor() { + effect(() => { + this.dataSource.data = this.sends(); + }); + } + + readonly editSend = output(); + readonly copySend = output(); + readonly removePassword = output(); + readonly deleteSend = output(); + + protected onEditSend(send: SendView): void { + this.editSend.emit(send); + } + + protected onCopySend(send: SendView): void { + this.copySend.emit(send); + } + + protected onRemovePassword(send: SendView): void { + this.removePassword.emit(send); + } + + protected onDeleteSend(send: SendView): void { + this.deleteSend.emit(send); + } +} diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index c9e867f8d3a..c0df34fadb2 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -358,6 +358,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci } submit = async () => { + let successToast: string = "editedItem"; if (this.cipherForm.invalid) { this.cipherForm.markAllAsTouched(); @@ -392,6 +393,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci // If the item is archived but user has lost archive permissions, unarchive the item. if (!userCanArchive && this.updatedCipherView.archivedDate) { this.updatedCipherView.archivedDate = null; + successToast = "itemRestored"; } const savedCipher = await this.addEditFormService.saveCipher( @@ -407,7 +409,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci title: null, message: this.i18nService.t( this.config.mode === "edit" || this.config.mode === "partial-edit" - ? "editedItem" + ? successToast : "addedItem", ), }); diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html index 9bf6dc32758..6e6bd70ec3f 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.html @@ -1,6 +1,9 @@

{{ "itemDetails" | i18n }}

+ @if (showArchiveBadge()) { + {{ "archived" | i18n }} + } - + @if (showImport()) { + + } diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.spec.ts b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.spec.ts new file mode 100644 index 00000000000..3f4a7500388 --- /dev/null +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.spec.ts @@ -0,0 +1,261 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, Subject } from "rxjs"; + +import { ClientType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; +import { generate_ssh_key } from "@bitwarden/sdk-internal"; + +import { SshImportPromptService } from "../../../services/ssh-import-prompt.service"; +import { CipherFormContainer } from "../../cipher-form-container"; + +import { SshKeySectionComponent } from "./sshkey-section.component"; + +jest.mock("@bitwarden/sdk-internal", () => { + return { + generate_ssh_key: jest.fn(), + }; +}); + +describe("SshKeySectionComponent", () => { + let fixture: ComponentFixture; + let component: SshKeySectionComponent; + const mockI18nService = mock(); + + let formStatusChange$: Subject; + + let cipherFormContainer: { + registerChildForm: jest.Mock; + patchCipher: jest.Mock; + getInitialCipherView: jest.Mock; + formStatusChange$: Subject; + }; + + let sdkClient$: BehaviorSubject; + let sdkService: { client$: BehaviorSubject }; + + let sshImportPromptService: { importSshKeyFromClipboard: jest.Mock }; + + let platformUtilsService: { getClientType: jest.Mock }; + + beforeEach(async () => { + formStatusChange$ = new Subject(); + + cipherFormContainer = { + registerChildForm: jest.fn(), + patchCipher: jest.fn(), + getInitialCipherView: jest.fn(), + formStatusChange$, + }; + + sdkClient$ = new BehaviorSubject({}); + sdkService = { client$: sdkClient$ }; + + sshImportPromptService = { + importSshKeyFromClipboard: jest.fn(), + }; + + platformUtilsService = { + getClientType: jest.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [SshKeySectionComponent], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { provide: CipherFormContainer, useValue: cipherFormContainer }, + { provide: SdkService, useValue: sdkService }, + { provide: SshImportPromptService, useValue: sshImportPromptService }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(SshKeySectionComponent); + component = fixture.componentInstance; + + // minimal required inputs + fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null }); + fixture.componentRef.setInput("disabled", false); + + (generate_ssh_key as unknown as jest.Mock).mockReset(); + }); + + it("registers the sshKeyDetails form with the container in the constructor", () => { + expect(cipherFormContainer.registerChildForm).toHaveBeenCalledTimes(1); + expect(cipherFormContainer.registerChildForm).toHaveBeenCalledWith( + "sshKeyDetails", + component.sshKeyForm, + ); + }); + + it("patches cipher sshKey whenever the form changes", () => { + component.sshKeyForm.setValue({ + privateKey: "priv", + publicKey: "pub", + keyFingerprint: "fp", + }); + + expect(cipherFormContainer.patchCipher).toHaveBeenCalledTimes(1); + const patchFn = cipherFormContainer.patchCipher.mock.calls[0][0] as (c: any) => any; + + const cipher: any = {}; + const patched = patchFn(cipher); + + expect(patched.sshKey).toBeInstanceOf(SshKeyView); + expect(patched.sshKey.privateKey).toBe("priv"); + expect(patched.sshKey.publicKey).toBe("pub"); + expect(patched.sshKey.keyFingerprint).toBe("fp"); + }); + + it("ngOnInit uses initial cipher sshKey (prefill) when present and does not generate", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue({ + sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" }, + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + + await component.ngOnInit(); + + expect(generate_ssh_key).not.toHaveBeenCalled(); + expect(component.sshKeyForm.get("privateKey")?.value).toBe("p1"); + expect(component.sshKeyForm.get("publicKey")?.value).toBe("p2"); + expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("p3"); + }); + + it("ngOnInit falls back to originalCipherView sshKey when prefill is missing", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue(null); + fixture.componentRef.setInput("originalCipherView", { + edit: true, + sshKey: { privateKey: "o1", publicKey: "o2", keyFingerprint: "o3" }, + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + + await component.ngOnInit(); + + expect(generate_ssh_key).not.toHaveBeenCalled(); + expect(component.sshKeyForm.get("privateKey")?.value).toBe("o1"); + expect(component.sshKeyForm.get("publicKey")?.value).toBe("o2"); + expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("o3"); + }); + + it("ngOnInit generates an ssh key when no sshKey exists and populates the form", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue(null); + fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null }); + + (generate_ssh_key as unknown as jest.Mock).mockReturnValue({ + privateKey: "genPriv", + publicKey: "genPub", + fingerprint: "genFp", + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + + await component.ngOnInit(); + + expect(generate_ssh_key).toHaveBeenCalledTimes(1); + expect(generate_ssh_key).toHaveBeenCalledWith("Ed25519"); + expect(component.sshKeyForm.get("privateKey")?.value).toBe("genPriv"); + expect(component.sshKeyForm.get("publicKey")?.value).toBe("genPub"); + expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("genFp"); + }); + + it("ngOnInit disables the form", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue({ + sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" }, + }); + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + + await component.ngOnInit(); + + expect(component.sshKeyForm.disabled).toBe(true); + }); + + it("sets showImport true when not Web and originalCipherView.edit is true", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue({ + sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" }, + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any); + + await component.ngOnInit(); + + expect(component.showImport()).toBe(true); + }); + + it("keeps showImport false when client type is Web", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue({ + sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" }, + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Web); + fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any); + + await component.ngOnInit(); + + expect(component.showImport()).toBe(false); + }); + + it("disables the ssh key form when formStatusChange emits enabled", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue({ + sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" }, + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + + await component.ngOnInit(); + + component.sshKeyForm.enable(); + expect(component.sshKeyForm.disabled).toBe(false); + + formStatusChange$.next("enabled"); + expect(component.sshKeyForm.disabled).toBe(true); + }); + + it("renders the import button only when showImport is true", async () => { + cipherFormContainer.getInitialCipherView.mockReturnValue({ + sshKey: { privateKey: "p1", publicKey: "p2", keyFingerprint: "p3" }, + }); + + platformUtilsService.getClientType.mockReturnValue(ClientType.Desktop); + fixture.componentRef.setInput("originalCipherView", { edit: true, sshKey: null } as any); + + await component.ngOnInit(); + fixture.detectChanges(); + + const importBtn = fixture.debugElement.query(By.css('[data-testid="import-privateKey"]')); + expect(importBtn).not.toBeNull(); + }); + + it("importSshKeyFromClipboard sets form values when a key is returned", async () => { + sshImportPromptService.importSshKeyFromClipboard.mockResolvedValue({ + privateKey: "cPriv", + publicKey: "cPub", + keyFingerprint: "cFp", + }); + + await component.importSshKeyFromClipboard(); + + expect(component.sshKeyForm.get("privateKey")?.value).toBe("cPriv"); + expect(component.sshKeyForm.get("publicKey")?.value).toBe("cPub"); + expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("cFp"); + }); + + it("importSshKeyFromClipboard does nothing when null is returned", async () => { + component.sshKeyForm.setValue({ privateKey: "a", publicKey: "b", keyFingerprint: "c" }); + sshImportPromptService.importSshKeyFromClipboard.mockResolvedValue(null); + + await component.importSshKeyFromClipboard(); + + expect(component.sshKeyForm.get("privateKey")?.value).toBe("a"); + expect(component.sshKeyForm.get("publicKey")?.value).toBe("b"); + expect(component.sshKeyForm.get("keyFingerprint")?.value).toBe("c"); + }); +}); diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts index 990de9574ab..32d572cf2f3 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, inject, Input, OnInit } from "@angular/core"; +import { Component, computed, DestroyRef, inject, input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { firstValueFrom } from "rxjs"; @@ -43,15 +43,9 @@ import { CipherFormContainer } from "../../cipher-form-container"; ], }) export class SshKeySectionComponent implements OnInit { - /** The original cipher */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() originalCipherView: CipherView; + readonly originalCipherView = input(null); - /** True when all fields should be disabled */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() disabled: boolean; + readonly disabled = input(false); /** * All form fields associated with the ssh key @@ -65,7 +59,14 @@ export class SshKeySectionComponent implements OnInit { keyFingerprint: [""], }); - showImport = false; + readonly showImport = computed(() => { + return ( + // Web does not support clipboard access + this.platformUtilsService.getClientType() !== ClientType.Web && + this.originalCipherView()?.edit + ); + }); + private destroyRef = inject(DestroyRef); constructor( @@ -90,7 +91,7 @@ export class SshKeySectionComponent implements OnInit { async ngOnInit() { const prefillCipher = this.cipherFormContainer.getInitialCipherView(); - const sshKeyView = prefillCipher?.sshKey ?? this.originalCipherView?.sshKey; + const sshKeyView = prefillCipher?.sshKey ?? this.originalCipherView()?.sshKey; if (sshKeyView) { this.setInitialValues(sshKeyView); @@ -100,11 +101,6 @@ export class SshKeySectionComponent implements OnInit { this.sshKeyForm.disable(); - // Web does not support clipboard access - if (this.platformUtilsService.getClientType() !== ClientType.Web) { - this.showImport = true; - } - // Disable the form if the cipher form container is enabled // to prevent user interaction this.cipherFormContainer.formStatusChange$ diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html index 67ded3f8358..2ba9a16357a 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html +++ b/libs/vault/src/cipher-view/attachments/attachments-v2-view.component.html @@ -9,7 +9,7 @@ {{ attachment.sizeName }} - +
-

+

{{ cipher().name }}

+ @if (showArchiveBadge()) { + {{ "archived" | i18n }} + }

diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts b/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts index ead2979fac7..ae78c49bdb4 100644 --- a/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentRef } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. @@ -21,6 +22,7 @@ describe("ItemDetailsV2Component", () => { let component: ItemDetailsV2Component; let fixture: ComponentFixture; let componentRef: ComponentRef; + let mockPlatformUtilsService: MockProxy; const cipher = { id: "cipher1", @@ -51,6 +53,8 @@ describe("ItemDetailsV2Component", () => { } as FolderView; beforeEach(async () => { + mockPlatformUtilsService = mock(); + await TestBed.configureTestingModule({ imports: [ItemDetailsV2Component], providers: [ @@ -61,6 +65,7 @@ describe("ItemDetailsV2Component", () => { useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) }, }, { provide: DomainSettingsService, useValue: { showFavicons$: of(true) } }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, ], }).compileComponents(); }); @@ -98,4 +103,31 @@ describe("ItemDetailsV2Component", () => { const owner = fixture.debugElement.query(By.css('[data-testid="owner"]')); expect(owner).toBeNull(); }); + + it("should show archive badge when cipher is archived and client is Desktop", () => { + jest.spyOn(mockPlatformUtilsService, "getClientType").mockReturnValue(ClientType.Desktop); + + const archivedCipher = { ...cipher, isArchived: true }; + componentRef.setInput("cipher", archivedCipher); + + expect((component as any).showArchiveBadge()).toBe(true); + }); + + it("should not show archive badge when cipher is not archived", () => { + jest.spyOn(mockPlatformUtilsService, "getClientType").mockReturnValue(ClientType.Desktop); + + const unarchivedCipher = { ...cipher, isArchived: false }; + componentRef.setInput("cipher", unarchivedCipher); + + expect((component as any).showArchiveBadge()).toBe(false); + }); + + it("should not show archive badge when client is not Desktop", () => { + jest.spyOn(mockPlatformUtilsService, "getClientType").mockReturnValue(ClientType.Web); + + const archivedCipher = { ...cipher, isArchived: true }; + componentRef.setInput("cipher", archivedCipher); + + expect((component as any).showArchiveBadge()).toBe(false); + }); }); diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts index 2c310daad76..8132780ccf4 100644 --- a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts @@ -9,11 +9,14 @@ import { fromEvent, map, startWith } from "rxjs"; // eslint-disable-next-line no-restricted-imports import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ClientType } from "@bitwarden/client-type"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { + BadgeModule, ButtonLinkDirective, CardComponent, FormFieldModule, @@ -35,6 +38,7 @@ import { OrgIconDirective } from "../../components/org-icon.directive"; OrgIconDirective, FormFieldModule, ButtonLinkDirective, + BadgeModule, ], }) export class ItemDetailsV2Component { @@ -85,7 +89,16 @@ export class ItemDetailsV2Component { } }); - constructor(private i18nService: I18nService) {} + protected readonly showArchiveBadge = computed(() => { + return ( + this.cipher().isArchived && this.platformUtilsService.getClientType() === ClientType.Desktop + ); + }); + + constructor( + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + ) {} toggleShowMore() { this.showAllDetails.update((value) => !value); diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html index 2566752813c..8d7673cc710 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html @@ -90,12 +90,15 @@ data-testid="copy-password" (click)="logCopyEvent()" > - - - {{ "changeAtRiskPassword" | i18n }} - - - + @if (showChangePasswordLink) { + + + {{ "vulnerablePassword" | i18n }} + + {{ "changeNow" | i18n }} + + + }