diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 99efec2fbbb..d1266a174e4 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..8327093441c 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@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - 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..46008206299 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "تعديل" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..fa28a709056 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Element arxivə göndərildi" }, + "itemWasUnarchived": { + "message": "Element arxivdən çıxarıldı" + }, "itemUnarchived": { "message": "Element arxivdən çıxarıldı" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Arxivi istifadə etmək üçün premium üzvlük tələb olunur." }, + "itemRestored": { + "message": "Element bərpa edildi" + }, "edit": { "message": "Düzəliş et" }, @@ -1251,7 +1257,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." @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Admin Konsolu" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "Avtomatik istifadəçi təsdiqi" + }, + "automaticUserConfirmationHint": { + "message": "Bu cihazın kilidi açıq olduqda gözləyən istifadəçiləri avtomatik təsdiqlə" + }, + "autoConfirmOnboardingCallout": { + "message": "Avtomatik istifadəçi təsdiqi ilə vaxta qənaət edin" + }, + "autoConfirmWarning": { + "message": "Bu, təşkilatınızın veri təhlükəsizliyinə təsir edə bilər. " + }, + "autoConfirmWarningLink": { + "message": "Risklər barədə öyrən" + }, + "autoConfirmSetup": { + "message": "Yeni istifadəçiləri avtomatik təsdiqlə" + }, + "autoConfirmSetupDesc": { + "message": "Bu cihazın kilidi açıq olduqda yeni istifadəçilər avtomatik təsdiqlənəcək." + }, + "autoConfirmSetupHint": { + "message": "Potensial təhlükəsizlik riskləri nələrdir?" + }, + "autoConfirmEnabled": { + "message": "Avtomatik təsdiq işə salındı" + }, + "availableNow": { + "message": "İndi mmövcuddur" + }, "accountSecurity": { "message": "Hesab güvənliyi" }, @@ -5664,6 +5703,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": "Zəifliyi olan parol." + }, + "changeNow": { + "message": "İndi dəyişdir" + }, "missingWebsite": { "message": "Əskik veb sayt" }, @@ -6039,5 +6084,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..53ce24563e0 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Рэдагаваць" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Кансоль адміністратара" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Бяспеке акаўнта" }, @@ -5664,6 +5703,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 +6084,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..751c7fe12df 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Елементът беше преместен в архива" }, + "itemWasUnarchived": { + "message": "Елементът беше изваден от архива" + }, "itemUnarchived": { "message": "Елементът беше изваден от архива" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "За да се възползвате от архивирането, трябва да ползвате платен абонамент." }, + "itemRestored": { + "message": "Записът бе възстановен" + }, "edit": { "message": "Редактиране" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Административна конзола" }, + "admin": { + "message": "Администратор" + }, + "automaticUserConfirmation": { + "message": "Автоматично потвърждение на потребителите" + }, + "automaticUserConfirmationHint": { + "message": "Автоматично потвърждение на потребителите, когато това устройство е отключено" + }, + "autoConfirmOnboardingCallout": { + "message": "Спестете време с автоматичното потвърждение на потребителите" + }, + "autoConfirmWarning": { + "message": "Това може да се отрази на сигурността на данните в организацията Ви. " + }, + "autoConfirmWarningLink": { + "message": "Научете повече за рисковете" + }, + "autoConfirmSetup": { + "message": "Автоматично потвърждаване на новите потребители" + }, + "autoConfirmSetupDesc": { + "message": "Новите потребители ще бъдат потвърждавани автоматично, докато това устройство е отключено." + }, + "autoConfirmSetupHint": { + "message": "Какви са възможните рискове за сигурността?" + }, + "autoConfirmEnabled": { + "message": "Автоматичното потвърждаване е включено" + }, + "availableNow": { + "message": "Налично сега" + }, "accountSecurity": { "message": "Защита на регистрацията" }, @@ -5664,6 +5703,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Този елемент за вписване е в риск и в него липсва уеб сайт. Добавете уеб сайт и сменете паролата, за по-добра сигурност." }, + "vulnerablePassword": { + "message": "Уязвима парола." + }, + "changeNow": { + "message": "Промяна сега" + }, "missingWebsite": { "message": "Липсващ уеб сайт" }, @@ -6039,5 +6084,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..d690cf29878 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "সম্পাদনা" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..75ab751df5f 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..53cb057c188 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edita" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Consola d'administració" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Seguretat del compte" }, @@ -5664,6 +5703,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 +6084,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..6905ba5f922 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Položka byla přesunuta do archivu" }, + "itemWasUnarchived": { + "message": "Položka byla odebrána z archivu" + }, "itemUnarchived": { "message": "Položka byla odebrána z archivu" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Pro použití funkce Archiv je potřebné prémiové členství." }, + "itemRestored": { + "message": "Položka byla obnovena" + }, "edit": { "message": "Upravit" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Konzole správce" }, + "admin": { + "message": "Administrátor" + }, + "automaticUserConfirmation": { + "message": "Automatické potvrzení uživatele" + }, + "automaticUserConfirmationHint": { + "message": "Automaticky potvrdit čekající uživatele, když je toto zařízení odemčeno" + }, + "autoConfirmOnboardingCallout": { + "message": "Ušetřete čas s automatickým potvrzením uživatele" + }, + "autoConfirmWarning": { + "message": "To by mohlo ovlivnit bezpečnost dat Vaší organizace. " + }, + "autoConfirmWarningLink": { + "message": "Více o rizicích" + }, + "autoConfirmSetup": { + "message": "Automaticky potvrdit nové uživatele" + }, + "autoConfirmSetupDesc": { + "message": "Noví uživatelé budou automaticky potvrzeni, když bude toto zařízení odemčeno." + }, + "autoConfirmSetupHint": { + "message": "Jaká jsou možná bezpečnostní rizika?" + }, + "autoConfirmEnabled": { + "message": "Zapnuto automatické potvrzení" + }, + "availableNow": { + "message": "Nyní k dispozici" + }, "accountSecurity": { "message": "Zabezpečení účtu" }, @@ -5664,6 +5703,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 +6084,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..2e8f20f51ac 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -344,16 +344,16 @@ "message": "Bitwarden for Business" }, "bitwardenAuthenticator": { - "message": "Dilyswr Bitwarden" + "message": "Dilysydd Bitwarden" }, "continueToAuthenticatorPageDesc": { "message": "Bitwarden Authenticator allows you to store authenticator keys and generate TOTP codes for 2-step verification flows. Learn more on the bitwarden.com website" }, "bitwardenSecretsManager": { - "message": "Bitwarden Secrets Manager" + "message": "Rheolydd Cyfrinachau Bitwarden" }, "continueToSecretsManagerPageDesc": { - "message": "Securely store, manage, and share developer secrets with Bitwarden Secrets Manager. Learn more on the bitwarden.com website." + "message": "Gallwch storio, rheoli a rhannu cyfrinachau datblygwyr yn ddiogel gyda Rheolydd Cyfrinachau Bitwarden. Dysgwch fwy ar wefan bitwarden.com." }, "passwordlessDotDev": { "message": "Passwordless.dev" @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Golygu" }, @@ -4152,7 +4158,7 @@ "description": "Text to display in overlay when the account is locked." }, "unlockYourAccountToViewAutofillSuggestions": { - "message": "Unlock your account to view autofill suggestions", + "message": "Datglowch eich cyfrif i weld argymhellion llenwi awtomatig", "description": "Text to display in overlay when the account is locked." }, "unlockAccount": { @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Diogelwch eich cyfrif" }, @@ -4936,7 +4975,7 @@ "message": "Download Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Download Bitwarden on all devices" + "message": "Lawrlwytho Bitwarden ar bob dyfais" }, "getTheMobileApp": { "message": "Get the mobile app" @@ -4966,7 +5005,7 @@ "message": "Premium" }, "unlockFeaturesWithPremium": { - "message": "Unlock reporting, emergency access, and more security features with Premium." + "message": "Datglowch nodweddion diogelwch megis adroddiadau, mynediad mewn argyfwng, a mwy drwy gyfrif Premium." }, "freeOrgsCannotUseAttachments": { "message": "Free organizations cannot use attachments" @@ -4975,7 +5014,7 @@ "message": "Filters" }, "filterVault": { - "message": "Filter vault" + "message": "Hidlo'r gell" }, "filterApplied": { "message": "One filter applied" @@ -5664,6 +5703,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" }, @@ -5855,7 +5900,7 @@ "message": "Great job securing your at-risk logins!" }, "upgradeNow": { - "message": "Upgrade now" + "message": "Uwchraddio nawr" }, "builtInAuthenticator": { "message": "Built-in authenticator" @@ -6039,5 +6084,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..6d06071480b 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Redigér" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Admin-konsol" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Kontosikkerhed" }, @@ -5664,6 +5703,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 +6084,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..c2b5b1d1ef2 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Eintrag wurde archiviert" }, + "itemWasUnarchived": { + "message": "Eintrag wird nicht mehr archiviert" + }, "itemUnarchived": { "message": "Eintrag wird nicht mehr archiviert" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Für die Nutzung des Archivs ist eine Premium-Mitgliedschaft erforderlich." }, + "itemRestored": { + "message": "Eintrag wurde wiederhergestellt" + }, "edit": { "message": "Bearbeiten" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Administrator-Konsole" }, + "admin": { + "message": "Administrator" + }, + "automaticUserConfirmation": { + "message": "Automatische Benutzerbestätigung" + }, + "automaticUserConfirmationHint": { + "message": "Ausstehende Benutzer automatisch bestätigen, während dieses Gerät entsperrt ist" + }, + "autoConfirmOnboardingCallout": { + "message": "Spare Zeit durch die automatische Benutzerbestätigung" + }, + "autoConfirmWarning": { + "message": "Dies könnte die Datensicherheit deiner Organisation beeinflussen. " + }, + "autoConfirmWarningLink": { + "message": "Erfahre mehr über die Risiken" + }, + "autoConfirmSetup": { + "message": "Neue Benutzer automatisch bestätigen" + }, + "autoConfirmSetupDesc": { + "message": "Neue Benutzer werden automatisch bestätigt, während dieses Gerät entsperrt ist." + }, + "autoConfirmSetupHint": { + "message": "Was sind die möglichen Sicherheitsrisiken?" + }, + "autoConfirmEnabled": { + "message": "Automatische Bestätigung aktiviert" + }, + "availableNow": { + "message": "Jetzt verfügbar" + }, "accountSecurity": { "message": "Kontosicherheit" }, @@ -5664,6 +5703,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Diese Zugangsdaten sind gefährdet und es fehlt eine Website. Füge eine Website hinzu und ändere das Passwort für mehr Sicherheit." }, + "vulnerablePassword": { + "message": "Gefährdetes Passwort." + }, + "changeNow": { + "message": "Jetzt ändern" + }, "missingWebsite": { "message": "Fehlende Website" }, @@ -6039,5 +6084,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..2cf5650bafd 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Επεξεργασία" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Κονσόλα Διαχειριστή" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Ασφάλεια λογαριασμού" }, @@ -5664,6 +5703,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 +6084,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..d3a393ecc37 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6063,7 @@ "placeholders": { "organization": { "content": "$1", - "example": "My Org Name" + "example": "My Org Name" } } }, @@ -6027,7 +6072,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..1940070310e 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4814,39 @@ "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 organisation’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..fcc7725f3fc 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4814,39 @@ "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 organisation’s data security. " + }, + "autoConfirmWarningLink": { + "message": "Learn about the risks" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..3ee3d31d56b 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Editar" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Consola de administrador" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Seguridad de la cuenta" }, @@ -5664,6 +5703,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 +6084,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..e39f87b6b86 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Muuda" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..81905e2ee20 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Editatu" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..057db9f0290 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "ویرایش" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "کنسول مدیر" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "امنیت حساب کاربری" }, @@ -5664,6 +5703,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 +6084,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..62785164c08 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Muokkaa" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Hallintapaneelista" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Tilin suojaus" }, @@ -5664,6 +5703,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 +6084,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..b485c7f26f5 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "I-edit" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..238a5ca5e68 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "L'élément a été envoyé à l'archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "L'élément a été désarchivé" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Une adhésion premium est requise pour utiliser Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Modifier" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Console Admin" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Sécurité du compte" }, @@ -5664,6 +5703,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 +6084,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..1544e6b822c 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Editar" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Consola do administrador" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Seguridade da conta" }, @@ -5664,6 +5703,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 +6084,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..6f8b6f235a7 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "הפריט נשלח לארכיון" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "הפריט הוסר מהארכיון" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "ערוך" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "מסוף מנהל" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "אבטחת החשבון" }, @@ -5664,6 +5703,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "כניסה זו נמצאת בסיכון וחסר בה אתר אינטרנט. הוסף אתר אינטרנט ושנה את הסיסמה לאבטחה חזקה יותר." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "לא נמצא אתר אינטרנט" }, @@ -6039,5 +6084,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..81202f05b58 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "संपादन करें" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..536529d1995 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Stavka poslana u arhivu" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Stavka vraćena iz arhive" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Uredi" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Konzola administratora" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Sigurnost računa" }, @@ -5664,6 +5703,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 +6084,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..049f2f776f1 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Az elem az archivumba került." }, + "itemWasUnarchived": { + "message": "Az elem visszavételre került az archivumból." + }, "itemUnarchived": { "message": "Az elemek visszavéelre kerültek az archivumból." }, @@ -585,6 +588,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" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Adminisztrátori konzol" }, + "admin": { + "message": "Adminisztrátor" + }, + "automaticUserConfirmation": { + "message": "Automatikus felhasználói megerősítés" + }, + "automaticUserConfirmationHint": { + "message": "A függőben lévő felhasználók automatikus megerősítése az eszköz zárolásának feloldásakor." + }, + "autoConfirmOnboardingCallout": { + "message": "Idő megtakarítás az automatikus felhasználói megerősítéssel" + }, + "autoConfirmWarning": { + "message": "Ez hatással lehet a szervezet adatbiztonságára." + }, + "autoConfirmWarningLink": { + "message": "További információ a kockázatokról" + }, + "autoConfirmSetup": { + "message": "Új felhasználók automatikus megerősítése" + }, + "autoConfirmSetupDesc": { + "message": "Az új felhasználók automatikusan megerősítésre kerülnek, amíg ez az eszköz fel van oldva." + }, + "autoConfirmSetupHint": { + "message": "Melyek a lehetséges biztonsági kockázatok?" + }, + "autoConfirmEnabled": { + "message": "Az automatikus megerősítés bekapcsolásra került." + }, + "availableNow": { + "message": "Elérhető most" + }, "accountSecurity": { "message": "Fiókbiztonság" }, @@ -5664,6 +5703,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 +6084,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..1a628e4e765 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Konsol Admin" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Keamanan akun" }, @@ -5664,6 +5703,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 +6084,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..3f662938cd7 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Elemento archiviato" }, + "itemWasUnarchived": { + "message": "Elemento rimosso dall'archivio" + }, "itemUnarchived": { "message": "Elemento rimosso dall'archivio" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Per utilizzare Archivio è necessario un abbonamento premium." }, + "itemRestored": { + "message": "L'elemento è stato ripristinato" + }, "edit": { "message": "Modifica" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Console di amministrazione" }, + "admin": { + "message": "Amministratore" + }, + "automaticUserConfirmation": { + "message": "Conferma automatica degli utenti" + }, + "automaticUserConfirmationHint": { + "message": "Conferma automaticamente gli utenti in sospeso mentre il dispositivo è sbloccato" + }, + "autoConfirmOnboardingCallout": { + "message": "Risparmia tempo con la conferma automatica degli utenti" + }, + "autoConfirmWarning": { + "message": "Potrebbe influenzare la sicurezza dei dati della tua organizzazione. " + }, + "autoConfirmWarningLink": { + "message": "Scopri quali sono i rischi" + }, + "autoConfirmSetup": { + "message": "Conferma automaticamente i nuovi utenti" + }, + "autoConfirmSetupDesc": { + "message": "I nuovi utenti saranno automaticamente confermati mentre questo dispositivo è sbloccato." + }, + "autoConfirmSetupHint": { + "message": "Quali sono i rischi potenziali per la sicurezza?" + }, + "autoConfirmEnabled": { + "message": "Conferma automatica attivata" + }, + "availableNow": { + "message": "Disponibile ora" + }, "accountSecurity": { "message": "Sicurezza dell'account" }, @@ -5664,6 +5703,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": "Password vulnerabile." + }, + "changeNow": { + "message": "Cambiala subito!" + }, "missingWebsite": { "message": "Sito web mancante" }, @@ -6039,5 +6084,8 @@ }, "whyAmISeeingThis": { "message": "Perché vedo questo avviso?" + }, + "resizeSideNavigation": { + "message": "Ridimensiona la navigazione laterale" } } diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 91c006fccca..b114ac090c0 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "アイテムはアーカイブに送信されました" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "アイテムはアーカイブから解除されました" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "アーカイブを使用するにはプレミアムメンバーシップが必要です。" }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "編集" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "管理コンソール" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "アカウントのセキュリティ" }, @@ -5664,6 +5703,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 +6084,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..feb90164977 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "ჩასწორება" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "ანგარიშის უსაფრთხოება" }, @@ -5664,6 +5703,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 +6084,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..e0bce7c9224 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..46f73f568cb 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "ಎಡಿಟ್" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..10cf245290d 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "항목이 보관함으로 이동되었습니다" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "항목 보관 해제됨" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "편집" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "관리자 콘솔" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "계정 보안" }, @@ -5664,6 +5703,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 +6084,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..f93790244cf 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Keisti" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Administratoriaus konsolės" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Paskyros saugumas" }, @@ -5664,6 +5703,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 +6084,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..596b4dfeaa0 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Vienums tika ievietots arhīvā" }, + "itemWasUnarchived": { + "message": "Vienums tika izņemts no arhīva" + }, "itemUnarchived": { "message": "Vienums tika izņemts no arhīva" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Ir nepieciešama Premium dalība, lai izmantotu arhīvu." }, + "itemRestored": { + "message": "Vienums tika atjaunots" + }, "edit": { "message": "Labot" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "pārvaldības konsolē," }, + "admin": { + "message": "Pārvaldītājs" + }, + "automaticUserConfirmation": { + "message": "Automātiska lietotāju apstiprināšana" + }, + "automaticUserConfirmationHint": { + "message": "Automātiski apstiprināt ierindotos lietotājus, kamēr šī ierīce ir atslēgta" + }, + "autoConfirmOnboardingCallout": { + "message": "Laika ietaupīšana ar automātisku lietotāju apstiprināšanu" + }, + "autoConfirmWarning": { + "message": "Tas varētu ietekmēt apvienības datu drošību. " + }, + "autoConfirmWarningLink": { + "message": "Uzzināt par riskiem" + }, + "autoConfirmSetup": { + "message": "Automātiski apstiprināt jaunus lietotājus" + }, + "autoConfirmSetupDesc": { + "message": "Jauni lietotāji tiks automātiski apstiprināti, kamēr šī ierīce ir atslēgta." + }, + "autoConfirmSetupHint": { + "message": "Kādi ir iespējamie drošības riski?" + }, + "autoConfirmEnabled": { + "message": "Automātiska apstiprināšana ieslēgta" + }, + "availableNow": { + "message": "Pieejams tagad" + }, "accountSecurity": { "message": "Konta drošība" }, @@ -5664,6 +5703,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 +6084,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..cc286de0c01 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "തിരുത്തുക" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..546c03c8bfb 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..e0bce7c9224 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..d5015c3a87d 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Rediger" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Administrasjonskonsoll" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Kontosikkerhet" }, @@ -5664,6 +5703,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 +6084,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..e0bce7c9224 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..ecb9fc7a297 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item naar archief verzonden" }, + "itemWasUnarchived": { + "message": "Item uit het archief gehaald" + }, "itemUnarchived": { "message": "Item uit het archief gehaald" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Je hebt een Premium-abonnement nodig om te kunnen archiveren." }, + "itemRestored": { + "message": "Item is hersteld" + }, "edit": { "message": "Bewerken" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Beheerconsole" }, + "admin": { + "message": "Beheerder" + }, + "automaticUserConfirmation": { + "message": "Automatische gebruikersbevestiging" + }, + "automaticUserConfirmationHint": { + "message": "Automatisch gebruikers in behandeling bevestigen wanneer dit apparaat is ontgrendeld" + }, + "autoConfirmOnboardingCallout": { + "message": "Bespaar tijd met automatische gebruikersbevestiging" + }, + "autoConfirmWarning": { + "message": "Dit kan van invloed zijn op de gegevensbeveiliging van je organisatie. " + }, + "autoConfirmWarningLink": { + "message": "Meer informatie over de risico's" + }, + "autoConfirmSetup": { + "message": "Automatisch nieuwe gebruikers bevestigen" + }, + "autoConfirmSetupDesc": { + "message": "Nieuwe gebruikers worden automatisch bevestigd wanneer dit apparaat is ontgrendeld." + }, + "autoConfirmSetupHint": { + "message": "Wat zijn de mogelijke veiligheidsrisico's?" + }, + "autoConfirmEnabled": { + "message": "Automatische bevestigen ingeschakeld" + }, + "availableNow": { + "message": "Nu beschikbaar" + }, "accountSecurity": { "message": "Accountbeveiliging" }, @@ -5664,6 +5703,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 +6084,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..e0bce7c9224 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..e0bce7c9224 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..72912399978 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Element został przeniesiony do archiwum" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Element został usunięty z archiwum" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Element został przywrócony" + }, "edit": { "message": "Edytuj" }, @@ -1047,10 +1053,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 +1435,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 +1777,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" @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Konsola administratora" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Bezpieczeństwo konta" }, @@ -5664,6 +5703,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Dane logowania są zagrożone i nie zawierają strony internetowej. Dodaj stronę internetową i zmień hasło." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Zmień teraz" + }, "missingWebsite": { "message": "Brak strony internetowej" }, @@ -5894,7 +5939,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 +5952,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 +5988,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 +6047,7 @@ "message": "Contact your admin to regain access." }, "leaveConfirmationDialogConfirmButton": { - "message": "Leave $ORGANIZATION$", + "message": "Opuść organizację $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -6011,10 +6056,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 +6084,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..5534c2b6ed7 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" @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "O item foi enviado para o arquivo" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "O item foi desarquivado" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Um plano Premium é necessário para usar o arquivamento." }, + "itemRestored": { + "message": "O item foi restaurado" + }, "edit": { "message": "Editar" }, @@ -895,7 +901,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 +1126,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 +1591,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 +1780,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 +1819,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 +1846,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 +1858,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 +2474,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 +2492,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 +2818,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 +4127,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 +4683,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 +4759,7 @@ } }, "autofillTitle": { - "message": "Preencher automaticamente - $ITEMNAME$", + "message": "Preencher - $ITEMNAME$", "description": "Title for a button that autofills a login item.", "placeholders": { "itemname": { @@ -4763,7 +4769,7 @@ } }, "autofillTitleWithField": { - "message": "Preencher automaticamente - $ITEMNAME$ - $FIELD$", + "message": "Preencher - $ITEMNAME$ - $FIELD$", "description": "Title for a button that autofills a login item.", "placeholders": { "itemname": { @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Painel de administração" }, + "admin": { + "message": "Administrador" + }, + "automaticUserConfirmation": { + "message": "Confirmação automática de usuários" + }, + "automaticUserConfirmationHint": { + "message": "Confirme automaticamente usuários pendentes quando este dispositivo for desbloqueado" + }, + "autoConfirmOnboardingCallout": { + "message": "Economize tempo com a confirmação automática de usuários" + }, + "autoConfirmWarning": { + "message": "Isso pode afetar a segurança dos dados da sua organização. " + }, + "autoConfirmWarningLink": { + "message": "Saiba mais sobre os riscos" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Segurança da conta" }, @@ -4853,7 +4892,7 @@ } }, "new": { - "message": "Novo" + "message": "Criar" }, "removeItem": { "message": "Remover $NAME$", @@ -5081,7 +5120,7 @@ } }, "autoFillOnPageLoad": { - "message": "Preencher automaticamente ao carregar a página?" + "message": "Preencher ao carregar a página?" }, "cardExpiredTitle": { "message": "Cartão vencido" @@ -5165,7 +5204,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 +5703,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 +5793,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 +6084,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..8643ac017f6 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "O item foi movido para o arquivo" }, + "itemWasUnarchived": { + "message": "O item foi desarquivado" + }, "itemUnarchived": { "message": "O item foi desarquivado" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "É necessária uma subscrição Premium para utilizar o Arquivo." }, + "itemRestored": { + "message": "O item foi restaurado" + }, "edit": { "message": "Editar" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Consola de administração" }, + "admin": { + "message": "Administrador" + }, + "automaticUserConfirmation": { + "message": "Confirmação automática do utilizador" + }, + "automaticUserConfirmationHint": { + "message": "Confirmar automaticamente os utilizadores pendentes enquanto este dispositivo estiver desbloqueado" + }, + "autoConfirmOnboardingCallout": { + "message": "Poupe tempo com a confirmação automática de utilizadores" + }, + "autoConfirmWarning": { + "message": "Isto pode afetar a segurança dos dados da sua organização. " + }, + "autoConfirmWarningLink": { + "message": "Saiba mais sobre os riscos" + }, + "autoConfirmSetup": { + "message": "Confirmar automaticamente novos utilizadores" + }, + "autoConfirmSetupDesc": { + "message": "Os novos utilizadores serão automaticamente confirmados enquanto este dispositivo estiver desbloqueado." + }, + "autoConfirmSetupHint": { + "message": "Quais são os riscos potenciais de segurança?" + }, + "autoConfirmEnabled": { + "message": "Confirmação automática ativada" + }, + "availableNow": { + "message": "Já disponível" + }, "accountSecurity": { "message": "Segurança da conta" }, @@ -5664,6 +5703,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 +6084,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..5cbab8b51b0 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Editare" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..0ca930ed9f3 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Элемент был отправлен в архив" }, + "itemWasUnarchived": { + "message": "Элемент был разархивирован" + }, "itemUnarchived": { "message": "Элемент был разархивирован" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Для использования архива требуется премиум-статус." }, + "itemRestored": { + "message": "Элемент восстановлен" + }, "edit": { "message": "Изменить" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "консоли администратора" }, + "admin": { + "message": "Администратор" + }, + "automaticUserConfirmation": { + "message": "Автоматическое подтверждение пользователя" + }, + "automaticUserConfirmationHint": { + "message": "Автоматически подтверждать ожидающих пользователей пока это устройство разблокировано" + }, + "autoConfirmOnboardingCallout": { + "message": "Экономьте время благодаря автоматическому подтверждению пользователей" + }, + "autoConfirmWarning": { + "message": "Это может повлиять на безопасность данных вашей организации. " + }, + "autoConfirmWarningLink": { + "message": "Узнайте о рисках" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Безопасность аккаунта" }, @@ -5664,6 +5703,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Этот логин подвержен риску и у него отсутствует веб-сайт. Добавьте веб-сайт и смените пароль для большей безопасности." }, + "vulnerablePassword": { + "message": "Уязвимый пароль." + }, + "changeNow": { + "message": "Изменить сейчас" + }, "missingWebsite": { "message": "Отсутствует сайт" }, @@ -6039,5 +6084,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..bed0cd73187 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "සංස්කරණය" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..5f5a1113fef 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Položka bola archivovaná" }, + "itemWasUnarchived": { + "message": "Položka bola odobraná z archívu" + }, "itemUnarchived": { "message": "Položka bola odobraná z archívu" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Na použitie archívu je potrebné prémiové členstvo." }, + "itemRestored": { + "message": "Položka bola obnovená" + }, "edit": { "message": "Upraviť" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Správcovská konzola" }, + "admin": { + "message": "Správca" + }, + "automaticUserConfirmation": { + "message": "Automatické potvrdenie používateľa" + }, + "automaticUserConfirmationHint": { + "message": "Automaticky potvrdzovať čakajúcich používateľov, keď je toto zariadenie odomknuté" + }, + "autoConfirmOnboardingCallout": { + "message": "Šetrite čas automatickým potvrdzovaním používateľa" + }, + "autoConfirmWarning": { + "message": "Môže mať vplyv na bezpečnosť údajov vašej organizácie. " + }, + "autoConfirmWarningLink": { + "message": "Dozvedieť sa viac o rizikách" + }, + "autoConfirmSetup": { + "message": "Automaticky potvrdzovať nových používateľov" + }, + "autoConfirmSetupDesc": { + "message": "Noví používatelia budú automaticky potvrdení, keď je toto zariadenie odomknuté." + }, + "autoConfirmSetupHint": { + "message": "Aké sú potenciálne bezpečnostné riziká?" + }, + "autoConfirmEnabled": { + "message": "Zapnuté automatické potvrdzovanie" + }, + "availableNow": { + "message": "Teraz dostupné" + }, "accountSecurity": { "message": "Zabezpečenie účtu" }, @@ -5664,6 +5703,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 +6084,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..bc5ed382df5 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Uredi" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..48ef707f942 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Ставка је послата у архиву" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Ставка враћена из архиве" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Премијум чланство је неопходно за употребу Архиве." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Уреди" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Администраторска конзола" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Безбедност налога" }, @@ -5664,6 +5703,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Ова пријава је ризична и недостаје веб локација. Додајте веб страницу и промените лозинку за јачу сигурност." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Недостаје веб страница" }, @@ -6039,5 +6084,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..e208186b408 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Objektet skickades till arkivet" }, + "itemWasUnarchived": { + "message": "Objektet har avarkiverats" + }, "itemUnarchived": { "message": "Objektet har avarkiverats" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Ett premium-medlemskap krävs för att använda Arkiv." }, + "itemRestored": { + "message": "Objektet har återställts" + }, "edit": { "message": "Redigera" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Adminkonsol" }, + "admin": { + "message": "Administratör" + }, + "automaticUserConfirmation": { + "message": "Automatisk bekräftelse av användare" + }, + "automaticUserConfirmationHint": { + "message": "Bekräfta automatiskt väntande användare medan enheten är olåst" + }, + "autoConfirmOnboardingCallout": { + "message": "Spara tid med automatisk användarbekräftelse" + }, + "autoConfirmWarning": { + "message": "Detta kan påverka din organisations datasäkerhet. " + }, + "autoConfirmWarningLink": { + "message": "Läs mer om riskerna" + }, + "autoConfirmSetup": { + "message": "Bekräfta nya användare automatiskt" + }, + "autoConfirmSetupDesc": { + "message": "Nya användare kommer automatiskt att bekräftas när denna enhet är upplåst." + }, + "autoConfirmSetupHint": { + "message": "Vilka är de potentiella säkerhetsriskerna?" + }, + "autoConfirmEnabled": { + "message": "Aktiverade automatisk bekräftelse" + }, + "availableNow": { + "message": "Tillgänglig nu" + }, "accountSecurity": { "message": "Kontosäkerhet" }, @@ -5664,6 +5703,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 +6084,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..3b1c93584b4 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "ஆவணம் காப்பகத்திற்கு அனுப்பப்பட்டது" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "காப்பகம் மீட்டெடுக்கப்பட்டது" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "காப்பகத்தைப் பயன்படுத்த பிரீமியம் உறுப்பினர் தேவை." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "திருத்து" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "நிர்வாகக் கன்சோல்" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "கணக்கு பாதுகாப்பு" }, @@ -5664,6 +5703,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "இந்த உள்நுழைவு ஆபத்தில் உள்ளது, மேலும் அதில் ஒரு வலைத்தளமும் இல்லை. வலுவான பாதுகாப்பிற்காக ஒரு வலைத்தளத்தைச் சேர்த்து கடவுச்சொல்லை மாற்றவும்." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "காணாமல் போன வலைத்தளம்" }, @@ -6039,5 +6084,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..e0bce7c9224 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Item was sent to archive" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Item was unarchived" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "A premium membership is required to use Archive." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Edit" }, @@ -4808,6 +4814,39 @@ "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Account security" }, @@ -5664,6 +5703,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 +6084,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..320d94d746a 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "ย้ายรายการไปที่จัดเก็บถาวรแล้ว" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "เลิกจัดเก็บถาวรรายการแล้ว" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "ต้องเป็นสมาชิกพรีเมียมจึงจะใช้งานฟีเจอร์จัดเก็บถาวรได้" }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "แก้ไข" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "คอนโซลผู้ดูแลระบบ" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "ความปลอดภัยของบัญชี" }, @@ -5664,6 +5703,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "ข้อมูลเข้าสู่ระบบนี้มีความเสี่ยงและไม่มีเว็บไซต์ เพิ่มเว็บไซต์และเปลี่ยนรหัสผ่านเพื่อความปลอดภัยที่รัดกุมยิ่งขึ้น" }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "ไม่มีเว็บไซต์" }, @@ -6039,5 +6084,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..35844d74041 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Kayıt arşive gönderildi" }, + "itemWasUnarchived": { + "message": "Kayıt arşivden çıkarıldı" + }, "itemUnarchived": { "message": "Kayıt arşivden çıkarıldı" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Arşivi kullanmak için premium üyelik gereklidir." }, + "itemRestored": { + "message": "Kayıt geri yüklendi" + }, "edit": { "message": "Düzenle" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Yönetici Konsolu" }, + "admin": { + "message": "Yönetici" + }, + "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" + }, + "autoConfirmSetup": { + "message": "Yeni kullanıcıları otomatik onayla" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Hesap güvenliği" }, @@ -5664,6 +5703,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 +6084,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..9b5b04dd7ec 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Запис архівовано" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Запис розархівовано" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "Для використання архіву необхідна передплата Premium." }, + "itemRestored": { + "message": "Item has been restored" + }, "edit": { "message": "Змінити" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "консолі адміністратора," }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Безпека облікового запису" }, @@ -5664,6 +5703,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "Цей запис ризикований, і не має адреси вебсайту. Додайте адресу вебсайту і змініть пароль для вдосконалення безпеки." }, + "vulnerablePassword": { + "message": "Vulnerable password." + }, + "changeNow": { + "message": "Change now" + }, "missingWebsite": { "message": "Немає вебсайту" }, @@ -6039,5 +6084,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..83e8af63982 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "Mục đã được chuyển vào kho lưu trữ" }, + "itemWasUnarchived": { + "message": "Item was unarchived" + }, "itemUnarchived": { "message": "Mục đã được bỏ lưu trữ" }, @@ -585,6 +588,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" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "Bảng điều khiển dành cho quản trị viên" }, + "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" + }, + "autoConfirmSetup": { + "message": "Automatically confirm new users" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "What are the potential security risks?" + }, + "autoConfirmEnabled": { + "message": "Turned on automatic confirmation" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "Bảo mật tài khoản" }, @@ -4936,7 +4975,7 @@ "message": "Tải xuống Bitwarden" }, "downloadBitwardenOnAllDevices": { - "message": "Tải xuống Bitwarden trên tất cả các thiết bị" + "message": "Tải xuống Bitwarden trên tất cả thiết bị" }, "getTheMobileApp": { "message": "Tải ứng dụng di động" @@ -5664,6 +5703,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 +6084,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..756b3a995c3 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "项目已发送到归档" }, + "itemWasUnarchived": { + "message": "项目已取消归档" + }, "itemUnarchived": { "message": "项目已取消归档" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "需要高级会员才能使用归档。" }, + "itemRestored": { + "message": "项目已恢复" + }, "edit": { "message": "编辑" }, @@ -3222,7 +3228,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为「$ACTION$」。", "placeholders": { "hours": { "content": "$1", @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "管理控制台" }, + "admin": { + "message": "管理员" + }, + "automaticUserConfirmation": { + "message": "自动用户确认" + }, + "automaticUserConfirmationHint": { + "message": "当此设备已解锁时,自动确认待处理的用户" + }, + "autoConfirmOnboardingCallout": { + "message": "通过自动用户确认节省时间" + }, + "autoConfirmWarning": { + "message": "这可能会影响您组织的数据安全。" + }, + "autoConfirmWarningLink": { + "message": "了解此风险" + }, + "autoConfirmSetup": { + "message": "自动确认新用户" + }, + "autoConfirmSetupDesc": { + "message": "当此设备已解锁时,新用户将被自动确认。" + }, + "autoConfirmSetupHint": { + "message": "有哪些潜在的安全风险?" + }, + "autoConfirmEnabled": { + "message": "启用了自动确认" + }, + "availableNow": { + "message": "目前可用" + }, "accountSecurity": { "message": "账户安全" }, @@ -4856,7 +4895,7 @@ "message": "新增" }, "removeItem": { - "message": "删除 $NAME$", + "message": "移除 $NAME$", "description": "Remove a selected option, such as a folder or collection", "placeholders": { "name": { @@ -5664,6 +5703,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "此登录存在风险且缺少网站。请添加网站并更改密码以增强安全性。" }, + "vulnerablePassword": { + "message": "易受攻击的密码。" + }, + "changeNow": { + "message": "立即更改" + }, "missingWebsite": { "message": "缺少网站" }, @@ -6039,5 +6084,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..bcbc4db394e 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -573,6 +573,9 @@ "itemWasSentToArchive": { "message": "項目已移至封存" }, + "itemWasUnarchived": { + "message": "已取消封存項目" + }, "itemUnarchived": { "message": "項目取消封存" }, @@ -585,6 +588,9 @@ "upgradeToUseArchive": { "message": "需要進階版會員才能使用封存功能。" }, + "itemRestored": { + "message": "已還原項目" + }, "edit": { "message": "編輯" }, @@ -4808,6 +4814,39 @@ "adminConsole": { "message": "管理控制台" }, + "admin": { + "message": "Admin" + }, + "automaticUserConfirmation": { + "message": "自動使用者確認" + }, + "automaticUserConfirmationHint": { + "message": "Automatically confirm pending users while this device is unlocked" + }, + "autoConfirmOnboardingCallout": { + "message": "Save time with automatic user confirmation" + }, + "autoConfirmWarning": { + "message": "可能影響您的組織資料安全性。" + }, + "autoConfirmWarningLink": { + "message": "了解風險" + }, + "autoConfirmSetup": { + "message": "自動確認新使用者" + }, + "autoConfirmSetupDesc": { + "message": "New users will be automatically confirmed while this device is unlocked." + }, + "autoConfirmSetupHint": { + "message": "潛在的安全性風險有哪些?" + }, + "autoConfirmEnabled": { + "message": "開啟自動確認" + }, + "availableNow": { + "message": "Available now" + }, "accountSecurity": { "message": "帳戶安全性" }, @@ -5664,6 +5703,12 @@ "changeAtRiskPasswordAndAddWebsite": { "message": "此登入資訊存在風險,且缺少網站。請新增網站並變更密碼以提升安全性。" }, + "vulnerablePassword": { + "message": "有安全疑慮的密碼。" + }, + "changeNow": { + "message": "立即變更" + }, "missingWebsite": { "message": "缺少網站" }, @@ -6039,5 +6084,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/overlay/inline-menu/content/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts index b61e5e19d53..c2f872d7ba5 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { InlineMenuElementPosition, InlineMenuPosition, @@ -62,8 +60,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte */ private inlineMenuEnabled = true; private mutationObserverIterations = 0; - private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout; - private handlePersistentLastChildOverrideTimeout: number | NodeJS.Timeout; + private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout | null = null; + private handlePersistentLastChildOverrideTimeout: number | NodeJS.Timeout | null = null; private lastElementOverrides: WeakMap = new WeakMap(); private readonly customElementDefaultStyles: Partial = { all: "initial", @@ -77,7 +75,21 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte }; constructor() { - this.setupMutationObserver(); + /** + * Sets up mutation observers for the inline menu elements, the menu container, and + * the document element. The mutation observers are used to remove any styles that + * are added to the inline menu elements by the website. They are also used to ensure + * that the inline menu elements are always present at the bottom of the menu container. + */ + this.htmlMutationObserver = new MutationObserver(this.handlePageMutations); + this.bodyMutationObserver = new MutationObserver(this.handlePageMutations); + this.inlineMenuElementsMutationObserver = new MutationObserver( + this.handleInlineMenuElementMutationObserverUpdate, + ); + this.containerElementMutationObserver = new MutationObserver( + this.handleContainerElementMutationObserverUpdate, + ); + this.observePageAttributes(); } /** @@ -181,12 +193,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * Updates the position of the inline menu button. */ private async appendButtonElement(): Promise { - if (!this.inlineMenuEnabled) { - return; - } - if (!this.buttonElement) { - this.createButtonElement(); + this.buttonElement = this.createButtonElement(); this.updateCustomElementDefaultStyles(this.buttonElement); } @@ -201,12 +209,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * Updates the position of the inline menu list. */ private async appendListElement(): Promise { - if (!this.inlineMenuEnabled) { - return; - } - if (!this.listElement) { - this.createListElement(); + this.listElement = this.createListElement(); this.updateCustomElementDefaultStyles(this.listElement); } @@ -257,16 +261,12 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * to create the element if it already exists in the DOM. */ private createButtonElement() { - if (!this.inlineMenuEnabled) { - return; - } - if (this.isFirefoxBrowser) { this.buttonElement = globalThis.document.createElement("div"); this.buttonElement.setAttribute("popover", "manual"); new AutofillInlineMenuButtonIframe(this.buttonElement); - return; + return this.buttonElement; } const customElementName = this.generateRandomCustomElementName(); @@ -282,6 +282,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.buttonElement = globalThis.document.createElement(customElementName); this.buttonElement.setAttribute("popover", "manual"); + return this.buttonElement; } /** @@ -289,16 +290,12 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * to create the element if it already exists in the DOM. */ private createListElement() { - if (!this.inlineMenuEnabled) { - return; - } - if (this.isFirefoxBrowser) { this.listElement = globalThis.document.createElement("div"); this.listElement.setAttribute("popover", "manual"); new AutofillInlineMenuListIframe(this.listElement); - return; + return this.listElement; } const customElementName = this.generateRandomCustomElementName(); @@ -314,6 +311,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.listElement = globalThis.document.createElement(customElementName); this.listElement.setAttribute("popover", "manual"); + return this.listElement; } /** @@ -330,27 +328,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.observeCustomElements(); } - /** - * Sets up mutation observers for the inline menu elements, the menu container, and - * the document element. The mutation observers are used to remove any styles that - * are added to the inline menu elements by the website. They are also used to ensure - * that the inline menu elements are always present at the bottom of the menu container. - */ - private setupMutationObserver = () => { - this.htmlMutationObserver = new MutationObserver(this.handlePageMutations); - this.bodyMutationObserver = new MutationObserver(this.handlePageMutations); - - this.inlineMenuElementsMutationObserver = new MutationObserver( - this.handleInlineMenuElementMutationObserverUpdate, - ); - - this.containerElementMutationObserver = new MutationObserver( - this.handleContainerElementMutationObserverUpdate, - ); - - this.observePageAttributes(); - }; - /** * Sets up mutation observers to verify that the inline menu * elements are not modified by the website. @@ -652,6 +629,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte return; } + if (!this.buttonElement) { + return; + } + const lastChild = containerElement.lastElementChild; const secondToLastChild = lastChild?.previousElementSibling; const lastChildIsInlineMenuList = lastChild === this.listElement; @@ -667,7 +648,8 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.lastElementOverrides.set(lastChild, lastChildEncounterCount + 1); } - if (this.lastElementOverrides.get(lastChild) >= 3) { + const lastChildEncounterCountAfterUpdate = this.lastElementOverrides.get(lastChild) || 0; + if (lastChildEncounterCountAfterUpdate >= 3) { this.handlePersistentLastChildOverride(lastChild); return; @@ -686,6 +668,9 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte (lastChildIsInlineMenuList && !secondToLastChildIsInlineMenuButton) || (lastChildIsInlineMenuButton && isInlineMenuListVisible) ) { + if (!this.listElement) { + return; + } containerElement.insertBefore(this.buttonElement, this.listElement); return; } 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(); update$ = this._triggerUpdate$.pipe( startWith(undefined), // Always emit once - tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)), switchMap(() => this._cachedState.state$.pipe( first(), // Only take the first value to avoid an infinite loop when updating the cache below - switchMap(async (cachedState) => { - const next = await this.getNextWebAddresses(cachedState); - if (next) { - await this._cachedState.update(() => next); - this.logService.info(`[PhishingDataService] cache updated`); - } + tap((cachedState) => { + void this._backgroundUpdate(cachedState); }), - retry({ - count: 3, - delay: (err, count) => { - this.logService.error( - `[PhishingDataService] Unable to update web addresses. Attempt ${count}.`, - err, - ); - return timer(5 * 60 * 1000); // 5 minutes - }, - resetOnSuccess: true, + catchError((err: unknown) => { + this.logService.error("[PhishingDataService] Background update failed to start.", err); + return EMPTY; }), - catchError( - ( - err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */, - ) => { - this.logService.error( - "[PhishingDataService] Retries unsuccessful. Unable to update web addresses.", - err, - ); - return EMPTY; - }, - ), ), ), share(), @@ -225,4 +200,47 @@ export class PhishingDataService { } return []; } + + // Runs the update flow in the background and retries up to 3 times on failure + private async _backgroundUpdate(prev: PhishingData | null): Promise { + this.logService.info(`[PhishingDataService] Update triggered...`); + const phishingData = prev ?? { + webAddresses: [], + timestamp: 0, + checksum: "", + applicationVersion: "", + }; + // Start time for logging performance of update + const startTime = Date.now(); + const maxAttempts = 3; + const delayMs = 5 * 60 * 1000; // 5 minutes + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const next = await this.getNextWebAddresses(phishingData); + if (next) { + await this._cachedState.update(() => next); + + // Performance logging + const elapsed = Date.now() - startTime; + this.logService.info(`[PhishingDataService] cache updated in ${elapsed}ms`); + } + return; + } catch (err) { + this.logService.error( + `[PhishingDataService] Unable to update web addresses. Attempt ${attempt}.`, + err, + ); + if (attempt < maxAttempts) { + await new Promise((res) => setTimeout(res, delayMs)); + } else { + const elapsed = Date.now() - startTime; + this.logService.error( + `[PhishingDataService] Retries unsuccessful after ${elapsed}ms. Unable to update web addresses.`, + err, + ); + } + } + } + } } diff --git a/apps/browser/src/platform/storage/memory-storage-service-interactions.spec.ts b/apps/browser/src/platform/storage/memory-storage-service-interactions.spec.ts index 8004559f57c..c223e4c4925 100644 --- a/apps/browser/src/platform/storage/memory-storage-service-interactions.spec.ts +++ b/apps/browser/src/platform/storage/memory-storage-service-interactions.spec.ts @@ -13,8 +13,7 @@ import { mockPorts } from "../../../spec/mock-port.spec-util"; import { BackgroundMemoryStorageService } from "./background-memory-storage.service"; import { ForegroundMemoryStorageService } from "./foreground-memory-storage.service"; -// These are succeeding individually but failing in a batch run - skipping for now -describe.skip("foreground background memory storage interaction", () => { +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/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts index 8f30d00cc31..f180564b912 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -9,9 +9,9 @@ import { map, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts index 1a3df238543..521d72bba0c 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts @@ -11,9 +11,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, I18nMockService, IconModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; diff --git a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts index 1f0d9f2a0c9..ddf50eb39bf 100644 --- a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog-container.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, input, OnInit } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { CenterPositionStrategy, DialogService } from "@bitwarden/components"; import { SendFormConfig } from "@bitwarden/send-ui"; diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts index 6e73d9811f2..dfbfabf8d5e 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -17,10 +17,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { mockAccountInfoWith } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { ButtonModule, NoItemsModule } from "@bitwarden/components"; import { diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index 89769bdd1ce..f36a475a805 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -13,7 +13,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator"; @@ -139,7 +139,7 @@ export class SendV2Component implements OnDestroy { .pipe(takeUntilDestroyed()) .subscribe(([emptyList, noFilteredResults, currentFilter]) => { if (currentFilter?.sendType !== null) { - this.title = this.sendTypeTitles[currentFilter.sendType] ?? "allSends"; + this.title = this.sendTypeTitles[currentFilter.sendType as SendType] ?? "allSends"; } else { this.title = "allSends"; } diff --git a/apps/browser/src/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/components/vault-v2/add-edit/add-edit-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html index 8f184c6a0c1..7230c565a48 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.html @@ -31,14 +31,34 @@ {{ "cancel" | i18n }} - + + @if (isEditMode) { + @if ((archiveFlagEnabled$ | async) && isCipherArchived) { + + } + @if ((userCanArchive$ | async) && canCipherBeArchived) { + + } + } + @if (canDeleteCipher$ | async) { + + } + diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts index f2c9d470816..4ffe44133d7 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts @@ -1,7 +1,8 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -12,13 +13,16 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info"; +import { DialogService } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, CipherFormConfig, CipherFormConfigService, CipherFormMode, @@ -45,15 +49,15 @@ describe("AddEditV2Component", () => { let cipherServiceMock: MockProxy; const buildConfigResponse = { originalCipher: {} } as CipherFormConfig; - const buildConfig = jest.fn((mode: CipherFormMode) => - Promise.resolve({ ...buildConfigResponse, mode }), - ); + const buildConfig = jest.fn((mode) => Promise.resolve({ ...buildConfigResponse, mode })); const queryParams$ = new BehaviorSubject({}); const disable = jest.fn(); const navigate = jest.fn(); const back = jest.fn().mockResolvedValue(null); const setHistory = jest.fn(); const collect = jest.fn().mockResolvedValue(null); + const openSimpleDialog = jest.fn().mockResolvedValue(true); + const cipherArchiveService = mock(); beforeEach(async () => { buildConfig.mockClear(); @@ -61,6 +65,10 @@ describe("AddEditV2Component", () => { navigate.mockClear(); back.mockClear(); collect.mockClear(); + openSimpleDialog.mockClear(); + + cipherArchiveService.hasArchiveFlagEnabled$ = of(true); + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); addEditCipherInfo$ = new BehaviorSubject(null); cipherServiceMock = mock({ @@ -83,10 +91,21 @@ describe("AddEditV2Component", () => { { provide: CipherAuthorizationService, useValue: { - canDeleteCipher$: jest.fn().mockReturnValue(true), + canDeleteCipher$: jest.fn().mockReturnValue(of(true)), }, }, { provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) }, + { + provide: CipherArchiveService, + useValue: cipherArchiveService, + }, + { + provide: ArchiveCipherUtilitiesService, + useValue: { + archiveCipher: jest.fn().mockResolvedValue(null), + unarchiveCipher: jest.fn().mockResolvedValue(null), + }, + }, ], }) .overrideProvider(CipherFormConfigService, { @@ -94,6 +113,11 @@ describe("AddEditV2Component", () => { buildConfig, }, }) + .overrideProvider(DialogService, { + useValue: { + openSimpleDialog, + }, + }) .compileComponents(); fixture = TestBed.createComponent(AddEditV2Component); @@ -356,6 +380,111 @@ describe("AddEditV2Component", () => { }); }); + describe("archive", () => { + it("calls archiveCipherUtilsService service to archive the cipher", async () => { + buildConfigResponse.originalCipher = { id: "222-333-444-5555", edit: true } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + await fixture.whenStable(); + await component.archive(); + + expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "222-333-444-5555" }), + true, + ); + }); + }); + + describe("unarchive", () => { + it("calls archiveCipherUtilsService service to unarchive the cipher", async () => { + buildConfigResponse.originalCipher = { + id: "222-333-444-5555", + archivedDate: new Date(), + edit: true, + } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + await component.unarchive(); + + expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "222-333-444-5555" }), + ); + }); + }); + + describe("archive button", () => { + beforeEach(() => { + // prevent form from rendering + jest.spyOn(component as any, "loading", "get").mockReturnValue(true); + buildConfigResponse.originalCipher = { archivedDate: undefined, edit: true } as Cipher; + }); + + it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + queryParams$.next({ cipherId: "222-333-444-5555" }); + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeTruthy(); + })); + + it("does not show the archive button when the user cannot archive", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + + it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + buildConfigResponse.originalCipher = { archivedDate: new Date(), edit: true } as Cipher; + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + }); + + describe("unarchive button", () => { + beforeEach(() => { + // prevent form from rendering + jest.spyOn(component as any, "loading", "get").mockReturnValue(true); + buildConfigResponse.originalCipher = { edit: true } as Cipher; + }); + + it("shows the unarchive button when the cipher is archived", fakeAsync(() => { + buildConfigResponse.originalCipher = { archivedDate: new Date(), edit: true } as Cipher; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeTruthy(); + })); + + it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => { + queryParams$.next({ cipherId: "222-333-444-5555" }); + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeFalsy(); + })); + }); + describe("delete", () => { it("dialogService openSimpleDialog called when deleteBtn is hit", async () => { const dialogSpy = jest diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts index 22aad854dd0..8704694fd53 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, OnInit, OnDestroy } from "@angular/core"; +import { Component, OnInit, OnDestroy, viewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; import { ActivatedRoute, Params, Router } from "@angular/router"; @@ -16,6 +16,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; @@ -31,6 +32,8 @@ import { ToastService, } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, + CipherFormComponent, CipherFormConfig, CipherFormConfigService, CipherFormGenerationService, @@ -159,6 +162,7 @@ export type AddEditQueryParams = Partial>; ], }) export class AddEditV2Component implements OnInit, OnDestroy { + readonly cipherFormComponent = viewChild(CipherFormComponent); headerText: string; config: CipherFormConfig; canDeleteCipher$: Observable; @@ -171,6 +175,18 @@ export class AddEditV2Component implements OnInit, OnDestroy { return this.config?.originalCipher?.id as CipherId; } + get cipher(): CipherView { + return new CipherView(this.config?.originalCipher); + } + + get canCipherBeArchived(): boolean { + return this.cipher?.canBeArchived; + } + + get isCipherArchived(): boolean { + return this.cipher?.isArchived; + } + private fido2PopoutSessionData$ = fido2PopoutSessionData$(); private fido2PopoutSessionData: Fido2SessionData; @@ -182,6 +198,16 @@ export class AddEditV2Component implements OnInit, OnDestroy { return BrowserPopupUtils.inSingleActionPopout(window, VaultPopoutType.addEditVaultItem); } + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; + + /** + * Flag to indicate if the user can archive items. + * @protected + */ + protected userCanArchive$ = this.accountService.activeAccount$.pipe( + switchMap((account) => this.archiveService.userCanArchive$(account.id)), + ); + constructor( private route: ActivatedRoute, private i18nService: I18nService, @@ -196,6 +222,8 @@ export class AddEditV2Component implements OnInit, OnDestroy { private dialogService: DialogService, protected cipherAuthorizationService: CipherAuthorizationService, private accountService: AccountService, + private archiveService: CipherArchiveService, + private archiveCipherUtilsService: ArchiveCipherUtilitiesService, ) { this.subscribeToParams(); } @@ -322,6 +350,10 @@ export class AddEditV2Component implements OnInit, OnDestroy { await BrowserApi.sendMessage("addEditCipherSubmitted"); } + get isEditMode(): boolean { + return ["edit", "partial-edit"].includes(this.config?.mode); + } + subscribeToParams(): void { this.route.queryParams .pipe( @@ -430,6 +462,40 @@ export class AddEditV2Component implements OnInit, OnDestroy { return this.i18nService.t(translation[type]); } + /** + * Update the cipher in the form after archiving/unarchiving. + * @param revisionDate The new revision date. + * @param archivedDate The new archived date (null if unarchived). + **/ + updateCipherFromArchive = (revisionDate: Date, archivedDate: Date | null) => { + this.cipherFormComponent().patchCipher((current) => { + current.revisionDate = revisionDate; + current.archivedDate = archivedDate; + return current; + }); + }; + + archive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true); + + if (!cipherResponse) { + return; + } + this.updateCipherFromArchive( + new Date(cipherResponse.revisionDate), + new Date(cipherResponse.archivedDate), + ); + }; + + unarchive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher); + + if (!cipherResponse) { + return; + } + this.updateCipherFromArchive(new Date(cipherResponse.revisionDate), null); + }; + delete = async () => { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "deleteItem" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html index b86ec24fd20..04b59d0ee0e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.html @@ -4,61 +4,62 @@ bitIconButton="bwi-ellipsis-v" size="small" [label]="'moreOptionsLabel' | i18n: cipher.name" - [disabled]="decryptionFailure" [bitMenuTriggerFor]="moreOptions" > - - - + + + + - - - - - - @if (canEdit) { - - } - - - {{ "clone" | i18n }} - - - {{ "assignToCollections" | i18n }} - - - @if (showArchive$ | async) { - @if (canArchive$ | async) { - - } @else { - + } @else { + + + } } } @if (canDelete$ | async) { diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts index bd9ce108522..6728249b788 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -158,14 +158,6 @@ describe("ItemMoreOptionsComponent", () => { expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); }); - it("does not show the exact match dialog when the default match strategy is Exact and autofill confirmation is not to be shown", async () => { - uriMatchStrategy$.next(UriMatchStrategy.Exact); - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - }); - describe("autofill confirmation dialog", () => { beforeEach(() => { uriMatchStrategy$.next(UriMatchStrategy.Domain); @@ -236,22 +228,30 @@ describe("ItemMoreOptionsComponent", () => { }); describe("URI match strategy handling", () => { + it("calls the passwordService to passwordRepromptCheck", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); + }); + describe("when the default URI match strategy is Exact", () => { beforeEach(() => { uriMatchStrategy$.next(UriMatchStrategy.Exact); }); - it("calls the passwordService to passwordRepromptCheck", async () => { - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); - mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); - - await component.doAutofill(); - - expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); - }); - - it("shows the exact match dialog", async () => { + it("shows the exact match dialog when the cipher has no saved URIs", async () => { autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [], + }, + })); await component.doAutofill(); @@ -266,6 +266,53 @@ describe("ItemMoreOptionsComponent", () => { expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); }); + + it("does not show the exact match dialog when the cipher has at least one non-exact match uri", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com", match: UriMatchStrategy.Domain }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows the exact match dialog when the cipher uris all have a match strategy of Exact", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com/a", match: UriMatchStrategy.Exact }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); }); describe("when the default URI match strategy is not Exact", () => { @@ -273,7 +320,45 @@ describe("ItemMoreOptionsComponent", () => { mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); uriMatchStrategy$.next(UriMatchStrategy.Domain); }); - it("does not show the exact match dialog", async () => { + + it("does not show the exact match dialog when the cipher has no saved URIs", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows the exact match dialog when the cipher has only exact match saved URIs", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://two.example.com/a", match: UriMatchStrategy.Exact }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("does not show the exact match dialog when the cipher has at least one uri without a match strategy of Exact", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); cipherService.getFullCipherView.mockImplementation(async (c) => ({ ...baseCipher, ...c, @@ -292,70 +377,6 @@ describe("ItemMoreOptionsComponent", () => { expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); }); - - it("shows the exact match dialog when the cipher has a single uri with a match strategy of Exact", async () => { - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [{ uri: "https://one.example.com", match: UriMatchStrategy.Exact }], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( - expect.objectContaining({ - title: expect.objectContaining({ key: "cannotAutofill" }), - content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), - type: "info", - }), - ); - expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); - expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); - }); - }); - - it("does not show the exact match dialog when the cipher has no uris", async () => { - mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); - }); - - it("does not show the exact match dialog when the cipher has a uri with a match strategy of Exact and a uri with a match strategy of Domain", async () => { - mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); - cipherService.getFullCipherView.mockImplementation(async (c) => ({ - ...baseCipher, - ...c, - login: { - ...baseCipher.login, - uris: [ - { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, - { uri: "https://page.example.com", match: UriMatchStrategy.Domain }, - ], - }, - })); - - autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); - - await component.doAutofill(); - - expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index c4353e17bef..ce797d9755e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -204,12 +204,15 @@ export class ItemMoreOptionsComponent { } const uris = cipher.login?.uris ?? []; - const cipherHasAllExactMatchLoginUris = - uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact); - const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$); - if (cipherHasAllExactMatchLoginUris || uriMatchStrategy === UriMatchStrategy.Exact) { + const showExactMatchDialog = + uris.length === 0 + ? uriMatchStrategy === UriMatchStrategy.Exact + : // all saved URIs are exact match + uris.every((u) => (u.match ?? uriMatchStrategy) === UriMatchStrategy.Exact); + + if (showExactMatchDialog) { await this.dialogService.openSimpleDialog({ title: { key: "cannotAutofill" }, content: { key: "cannotAutofillExactMatch" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index 883d17b61c3..e6dffdaff08 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -10,6 +10,10 @@ import { BehaviorSubject, Observable, Subject, of } from "rxjs"; import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; import { NudgeType, NudgesService } from "@bitwarden/angular/vault"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { + AutoConfirmExtensionSetupDialogComponent, + AutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; import { CurrentAccountComponent } from "@bitwarden/browser/auth/popup/account-switching/current-account.component"; import AutofillService from "@bitwarden/browser/autofill/services/autofill.service"; import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component"; @@ -136,6 +140,7 @@ class VaultListItemsContainerStubComponent { const mockDialogRef = { close: jest.fn(), afterClosed: jest.fn().mockReturnValue(of(undefined)), + closed: of(undefined), } as unknown as import("@bitwarden/components").DialogRef; jest @@ -145,6 +150,11 @@ jest jest .spyOn(DecryptionFailureDialogComponent, "open") .mockImplementation((_: DialogService, _params: any) => mockDialogRef as any); + +const autoConfirmDialogSpy = jest + .spyOn(AutoConfirmExtensionSetupDialogComponent, "open") + .mockImplementation((_: DialogService) => mockDialogRef as any); + jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(false); jest.spyOn(BrowserPopupUtils, "openCurrentPagePopout").mockResolvedValue(); @@ -222,6 +232,13 @@ describe("VaultV2Component", () => { getFeatureFlag$: jest.fn().mockImplementation((_flag: string) => of(false)), }; + const autoConfirmSvc = { + configuration$: jest.fn().mockReturnValue(of({})), + canManageAutoConfirm$: jest.fn().mockReturnValue(of(false)), + upsert: jest.fn().mockResolvedValue(undefined), + autoConfirmUser: jest.fn().mockResolvedValue(undefined), + }; + beforeEach(async () => { jest.clearAllMocks(); await TestBed.configureTestingModule({ @@ -275,6 +292,10 @@ describe("VaultV2Component", () => { provide: SearchService, useValue: { isCipherSearching$: of(false) }, }, + { + provide: AutomaticUserConfirmationService, + useValue: autoConfirmSvc, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); @@ -588,4 +609,86 @@ describe("VaultV2Component", () => { const spotlights = queryAllSpotlights(fixture); expect(spotlights.length).toBe(0); })); + + describe("AutoConfirmExtensionSetupDialog", () => { + beforeEach(() => { + autoConfirmDialogSpy.mockClear(); + }); + + it("opens dialog when canManage is true and showBrowserNotification is undefined", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(true)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: false, + showSetupDialog: true, + showBrowserNotification: undefined, + }), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).toHaveBeenCalledWith(expect.any(Object)); + })); + + it("does not open dialog when showBrowserNotification is false", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(true)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: false, + showSetupDialog: true, + showBrowserNotification: false, + }), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).not.toHaveBeenCalled(); + })); + + it("does not open dialog when showBrowserNotification is true", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(true)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: true, + showSetupDialog: true, + showBrowserNotification: true, + }), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).not.toHaveBeenCalled(); + })); + + it("does not open dialog when canManage is false even if showBrowserNotification is undefined", fakeAsync(() => { + autoConfirmSvc.canManageAutoConfirm$.mockReturnValue(of(false)); + autoConfirmSvc.configuration$.mockReturnValue( + of({ + enabled: false, + showSetupDialog: true, + showBrowserNotification: undefined, + }), + ); + + const fixture = TestBed.createComponent(VaultV2Component); + const component = fixture.componentInstance; + + void component.ngOnInit(); + tick(); + + expect(autoConfirmDialogSpy).not.toHaveBeenCalled(); + })); + }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 30d1d21abfb..761b366bcd2 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -15,6 +15,7 @@ import { shareReplay, switchMap, take, + withLatestFrom, tap, BehaviorSubject, } from "rxjs"; @@ -25,6 +26,11 @@ import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { DeactivatedOrg, NoResults, VaultOpen } from "@bitwarden/assets/svg"; +import { + AutoConfirmExtensionSetupDialogComponent, + AutoConfirmState, + AutomaticUserConfirmationService, +} from "@bitwarden/auto-confirm"; 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"; @@ -41,6 +47,7 @@ import { ButtonModule, DialogService, NoItemsModule, + ToastService, TypographyModule, } from "@bitwarden/components"; import { @@ -267,6 +274,8 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { private introCarouselService: IntroCarouselService, private nudgesService: NudgesService, private router: Router, + private autoConfirmService: AutomaticUserConfirmationService, + private toastService: ToastService, private vaultProfileService: VaultProfileService, private billingAccountService: BillingAccountProfileStateService, private liveAnnouncer: LiveAnnouncer, @@ -329,6 +338,36 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { }); }); + const autoConfirmState$ = this.autoConfirmService.configuration$(this.activeUserId); + + combineLatest([ + this.autoConfirmService.canManageAutoConfirm$(this.activeUserId), + autoConfirmState$, + ]) + .pipe( + filter(([canManage, state]) => canManage && state.showBrowserNotification === undefined), + take(1), + switchMap(() => AutoConfirmExtensionSetupDialogComponent.open(this.dialogService).closed), + withLatestFrom(autoConfirmState$, this.accountService.activeAccount$.pipe(getUserId)), + switchMap(([result, state, userId]) => { + const newState: AutoConfirmState = { + ...state, + enabled: result ?? false, + showBrowserNotification: !result, + }; + + if (result) { + this.toastService.showToast({ + message: this.i18nService.t("autoConfirmEnabled"), + variant: "success", + }); + } + + return this.autoConfirmService.upsert(userId, newState); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); await this.vaultItemsTransferService.enforceOrganizationDataOwnership(this.activeUserId); this.readySubject.next(true); diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html index 9b8380a4214..d2a4aaab3f0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html @@ -3,37 +3,47 @@ - + @if (cipher) { + + } - - - - - + @if (!cipher.isDeleted) { + + } + @if (cipher.isDeleted && cipher.permissions.restore) { + + } + + @if ((archiveFlagEnabled$ | async) && cipher.isArchived) { + + } + @if ((userCanArchive$ | async) && cipher.canBeArchived) { + + } + @if (canDeleteCipher$ | async) { + + } + diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts index 3d4fdb2e9f9..9c536a7e85a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.spec.ts @@ -1,9 +1,13 @@ -import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing"; +import { ComponentFixture, fakeAsync, flush, TestBed, tick } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { of, Subject } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AUTOFILL_ID, @@ -11,20 +15,32 @@ import { COPY_USERNAME_ID, COPY_VERIFICATION_CODE_ID, } from "@bitwarden/common/autofill/constants"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { EventType } from "@bitwarden/common/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { TaskService } from "@bitwarden/common/vault/tasks"; import { DialogService, ToastService } from "@bitwarden/components"; -import { CopyCipherFieldService, PasswordRepromptService } from "@bitwarden/vault"; +import { + ArchiveCipherUtilitiesService, + CopyCipherFieldService, + PasswordRepromptService, +} from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/browser/browser-popup-utils"; @@ -62,7 +78,9 @@ describe("ViewV2Component", () => { username: "test-username", password: "test-password", totp: "123", + uris: [], }, + card: {}, } as unknown as CipherView; const mockPasswordRepromptService = { @@ -84,6 +102,8 @@ describe("ViewV2Component", () => { softDeleteWithServer: jest.fn().mockResolvedValue(undefined), }; + const cipherArchiveService = mock(); + beforeEach(async () => { mockCipherService.cipherViews$.mockClear(); mockCipherService.deleteWithServer.mockClear(); @@ -97,6 +117,10 @@ describe("ViewV2Component", () => { back.mockClear(); showToast.mockClear(); showPasswordPrompt.mockClear(); + cipherArchiveService.hasArchiveFlagEnabled$ = of(true); + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + cipherArchiveService.archiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); + cipherArchiveService.unarchiveWithServer.mockResolvedValue({ id: "122-333-444" } as CipherData); await TestBed.configureTestingModule({ imports: [ViewV2Component], @@ -142,6 +166,61 @@ describe("ViewV2Component", () => { provide: PasswordRepromptService, useValue: mockPasswordRepromptService, }, + { + provide: CipherArchiveService, + useValue: cipherArchiveService, + }, + { + provide: OrganizationService, + useValue: mock(), + }, + { + provide: CollectionService, + useValue: mock(), + }, + { + provide: FolderService, + useValue: mock(), + }, + { + provide: TaskService, + useValue: mock(), + }, + { + provide: ApiService, + useValue: mock(), + }, + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getIconsUrl: () => "https://example.com", + }), + }, + }, + { + provide: DomainSettingsService, + useValue: { + showFavicons$: of(true), + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: jest.fn().mockReturnValue(of(false)), + }, + }, + { + provide: ArchiveCipherUtilitiesService, + useValue: { + archiveCipher: jest.fn().mockResolvedValue(null), + unarchiveCipher: jest.fn().mockResolvedValue(null), + }, + }, + { + provide: CipherRiskService, + useValue: mock(), + }, ], }) .overrideProvider(DialogService, { @@ -154,6 +233,7 @@ describe("ViewV2Component", () => { fixture = TestBed.createComponent(ViewV2Component); component = fixture.componentInstance; fixture.detectChanges(); + (component as any).showFooter$ = of(true); }); describe("queryParams", () => { @@ -352,6 +432,93 @@ describe("ViewV2Component", () => { })); }); + describe("archive button", () => { + it("shows the archive button when the user can archive and the cipher can be archived", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true)); + component.cipher = { ...mockCipher, canBeArchived: true } as CipherView; + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeTruthy(); + })); + + it("does not show the archive button when the user cannot archive", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(false)); + component.cipher = { ...mockCipher, canBeArchived: true, isDeleted: false } as CipherView; + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + + it("does not show the archive button when the cipher cannot be archived", fakeAsync(() => { + jest.spyOn(component["archiveService"], "userCanArchive$").mockReturnValueOnce(of(true)); + component.cipher = { ...mockCipher, archivedDate: new Date(), edit: true } as CipherView; + + tick(); + fixture.detectChanges(); + + const archiveBtn = fixture.debugElement.query(By.css("button[biticonbutton='bwi-archive']")); + expect(archiveBtn).toBeFalsy(); + })); + }); + + describe("unarchive button", () => { + it("shows the unarchive button when the cipher is archived", fakeAsync(() => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeTruthy(); + })); + + it("does not show the unarchive button when the cipher is not archived", fakeAsync(() => { + component.cipher = { ...mockCipher, archivedDate: undefined } as CipherView; + + tick(); + fixture.detectChanges(); + + const unarchiveBtn = fixture.debugElement.query( + By.css("button[biticonbutton='bwi-unarchive']"), + ); + expect(unarchiveBtn).toBeFalsy(); + })); + }); + + describe("archive", () => { + beforeEach(() => { + component.cipher = { ...mockCipher, canBeArchived: true } as CipherView; + }); + + it("calls archive service to archive the cipher", async () => { + await component.archive(); + + expect(component["archiveCipherUtilsService"].archiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "122-333-444" }), + true, + ); + }); + }); + + describe("unarchive", () => { + it("calls archive service to unarchive the cipher", async () => { + component.cipher = { ...mockCipher, isArchived: true } as CipherView; + + await component.unarchive(); + + expect(component["archiveCipherUtilsService"].unarchiveCipher).toHaveBeenCalledWith( + expect.objectContaining({ id: "122-333-444" }), + ); + }); + }); + describe("delete", () => { beforeEach(() => { component.cipher = mockCipher; diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index 1dea91c0b9f..64fa42bb2ba 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -25,6 +25,7 @@ import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; @@ -42,6 +43,7 @@ import { ToastService, } from "@bitwarden/components"; import { + ArchiveCipherUtilitiesService, ChangeLoginPasswordService, CipherViewComponent, CopyCipherFieldService, @@ -114,6 +116,10 @@ export class ViewV2Component { senderTabId?: number; protected showFooter$: Observable; + protected userCanArchive$ = this.accountService.activeAccount$ + .pipe(getUserId) + .pipe(switchMap((userId) => this.archiveService.userCanArchive$(userId))); + protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$; constructor( private passwordRepromptService: PasswordRepromptService, @@ -131,6 +137,8 @@ export class ViewV2Component { protected cipherAuthorizationService: CipherAuthorizationService, private copyCipherFieldService: CopyCipherFieldService, private popupScrollPositionService: VaultPopupScrollPositionService, + private archiveService: CipherArchiveService, + private archiveCipherUtilsService: ArchiveCipherUtilitiesService, ) { this.subscribeToParams(); } @@ -272,6 +280,24 @@ export class ViewV2Component { }); }; + archive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.archiveCipher(this.cipher, true); + + if (!cipherResponse) { + return; + } + this.cipher.archivedDate = new Date(cipherResponse.archivedDate); + }; + + unarchive = async () => { + const cipherResponse = await this.archiveCipherUtilsService.unarchiveCipher(this.cipher); + + if (!cipherResponse) { + return; + } + this.cipher.archivedDate = null; + }; + protected deleteCipher() { return this.cipher.isDeleted ? this.cipherService.deleteWithServer(this.cipher.id, this.activeUserId) diff --git a/apps/browser/src/vault/popup/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 a29a13cb010..7252dd32afc 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; - 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/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index 7803f6f94d4..91e579c26c1 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -9,9 +9,9 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NodeUtils } from "@bitwarden/node/node-utils"; import { Response } from "../../../models/response"; diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index bf53c8a5cb9..2c6d41d66ac 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -5,9 +5,9 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; import { CliUtils } from "../../../utils"; diff --git a/apps/cli/src/tools/send/commands/receive.command.ts b/apps/cli/src/tools/send/commands/receive.command.ts index a412f7c1667..5cbf458c87f 100644 --- a/apps/cli/src/tools/send/commands/receive.command.ts +++ b/apps/cli/src/tools/send/commands/receive.command.ts @@ -13,11 +13,11 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { KeyService } from "@bitwarden/key-management"; import { NodeUtils } from "@bitwarden/node/node-utils"; diff --git a/apps/cli/src/tools/send/commands/template.command.ts b/apps/cli/src/tools/send/commands/template.command.ts index c1c2c97b03d..09213ac5fa8 100644 --- a/apps/cli/src/tools/send/commands/template.command.ts +++ b/apps/cli/src/tools/send/commands/template.command.ts @@ -1,4 +1,4 @@ -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; import { TemplateResponse } from "../../../models/response/template.response"; diff --git a/apps/cli/src/tools/send/models/send-access.response.ts b/apps/cli/src/tools/send/models/send-access.response.ts index 07877bfb548..7bd54801307 100644 --- a/apps/cli/src/tools/send/models/send-access.response.ts +++ b/apps/cli/src/tools/send/models/send-access.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../models/response/base.response"; diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index a0c1d3f83c6..b7655226be0 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -1,8 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../models/response/base.response"; diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 33bf4518ccd..869d77a379c 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -7,7 +7,7 @@ import * as chalk from "chalk"; import { program, Command, Option, OptionValues } from "commander"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseProgram } from "../../base-program"; import { Response } from "../../models/response"; diff --git a/apps/cli/src/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/custom-appx-manifest.xml b/apps/desktop/custom-appx-manifest.xml new file mode 100644 index 00000000000..2f7796c97cf --- /dev/null +++ b/apps/desktop/custom-appx-manifest.xml @@ -0,0 +1,111 @@ + + + + + + + + ${displayName} + ${publisherDisplayName} + A secure and free password manager for all of your devices. + assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index f5e5cf7ee18..24c280d90aa 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]] @@ -1994,11 +1994,10 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ - "byteorder", "lazy_static", "libm", "num-integer", @@ -2653,9 +2652,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.6" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", "digest", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 86eb507a6c1..aecad6cb1d2 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -50,7 +50,7 @@ oo7 = "=0.5.0" pin-project = "=1.1.10" pkcs8 = "=0.10.2" rand = "=0.9.2" -rsa = "=0.9.6" +rsa = "=0.9.10" russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" secmem-proc = "=0.3.7" 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/build.js b/apps/desktop/desktop_native/build.js index 54a6dba8326..b20aa7e5af8 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -20,47 +20,79 @@ fs.mkdirSync(path.join(__dirname, "dist"), { recursive: true }); const args = process.argv.slice(2); // Get arguments passed to the script const mode = args.includes("--release") ? "release" : "debug"; +const isRelease = mode === "release"; const targetArg = args.find(arg => arg.startsWith("--target=")); const target = targetArg ? targetArg.split("=")[1] : null; let crossPlatform = process.argv.length > 2 && process.argv[2] === "cross-platform"; +/** + * Execute a command. + * @param {string} bin Executable to run. + * @param {string[]} args Arguments for executable. + * @param {string} [workingDirectory] Path to working directory, relative to the script directory. Defaults to the script directory. + * @param {string} [useShell] Whether to use a shell to execute the command. Defaults to false. + */ +function runCommand(bin, args, workingDirectory = "", useShell = false) { + const options = { stdio: 'inherit', cwd: path.resolve(__dirname, workingDirectory), shell: useShell } + console.debug("Running command:", bin, args, options) + child_process.execFileSync(bin, args, options) +} + function buildNapiModule(target, release = true) { - const targetArg = target ? `--target ${target}` : ""; + const targetArg = target ? `--target=${target}` : ""; const releaseArg = release ? "--release" : ""; - child_process.execSync(`npm run build -- ${releaseArg} ${targetArg}`, { stdio: 'inherit', cwd: path.join(__dirname, "napi") }); + const crossCompileArg = effectivePlatform(target) !== process.platform ? "--cross-compile" : ""; + runCommand("npm", ["run", "build", "--", crossCompileArg, releaseArg, targetArg].filter(s => s != ''), "./napi", true); +} + +/** + * Build a Rust binary with Cargo. + * + * If {@link target} is specified, cross-compilation helpers are used to build if necessary, and the resulting + * binary is copied to the `dist` folder. + * @param {string} bin Name of cargo binary package in `desktop_native` workspace. + * @param {string?} target Rust compiler target, e.g. `aarch64-pc-windows-msvc`. + * @param {boolean} release Whether to build in release mode. + */ +function cargoBuild(bin, target, release) { + const targetArg = target ? `--target=${target}` : ""; + const releaseArg = release ? "--release" : ""; + const args = ["build", "--bin", bin, releaseArg, targetArg] + // Use cross-compilation helper if necessary + if (effectivePlatform(target) === "win32" && process.platform !== "win32") { + args.unshift("xwin") + } + runCommand("cargo", args.filter(s => s != '')) + + // Infer the architecture and platform if not passed explicitly + let nodeArch, platform; + if (target) { + nodeArch = rustTargetsMap[target].nodeArch; + platform = rustTargetsMap[target].platform; + } + else { + nodeArch = process.arch; + platform = process.platform; + } + + // Copy the resulting binary to the dist folder + const profileFolder = isRelease ? "release" : "debug"; + const ext = platform === "win32" ? ".exe" : ""; + const src = path.join(__dirname, "target", target ? target : "", profileFolder, `${bin}${ext}`) + const dst = path.join(__dirname, "dist", `${bin}.${platform}-${nodeArch}${ext}`) + console.log(`Copying ${src} to ${dst}`); + fs.copyFileSync(src, dst); } function buildProxyBin(target, release = true) { - const targetArg = target ? `--target ${target}` : ""; - const releaseArg = release ? "--release" : ""; - child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")}); - - if (target) { - // Copy the resulting binary to the dist folder - const targetFolder = release ? "release" : "debug"; - const ext = process.platform === "win32" ? ".exe" : ""; - const nodeArch = rustTargetsMap[target].nodeArch; - fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `desktop_proxy${ext}`), path.join(__dirname, "dist", `desktop_proxy.${process.platform}-${nodeArch}${ext}`)); - } + cargoBuild("desktop_proxy", target, release) } function buildImporterBinaries(target, release = true) { // These binaries are only built for Windows, so we can skip them on other platforms - if (process.platform !== "win32") { - return; - } - - const bin = "bitwarden_chromium_import_helper"; - const targetArg = target ? `--target ${target}` : ""; - const releaseArg = release ? "--release" : ""; - child_process.execSync(`cargo build --bin ${bin} ${releaseArg} ${targetArg}`); - - if (target) { - // Copy the resulting binary to the dist folder - const targetFolder = release ? "release" : "debug"; - const nodeArch = rustTargetsMap[target].nodeArch; - fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `${bin}.exe`), path.join(__dirname, "dist", `${bin}.${process.platform}-${nodeArch}.exe`)); + if (effectivePlatform(target) == "win32") { + cargoBuild("bitwarden_chromium_import_helper", target, release) } } @@ -69,17 +101,29 @@ function buildProcessIsolation() { return; } - child_process.execSync(`cargo build --release`, { - stdio: 'inherit', - cwd: path.join(__dirname, "process_isolation") - }); + runCommand("cargo", ["build", "--package", "process_isolation", "--release"]); console.log("Copying process isolation library to dist folder"); fs.copyFileSync(path.join(__dirname, "target", "release", "libprocess_isolation.so"), path.join(__dirname, "dist", `libprocess_isolation.so`)); } function installTarget(target) { - child_process.execSync(`rustup target add ${target}`, { stdio: 'inherit', cwd: __dirname }); + runCommand("rustup", ["target", "add", target]); + // Install cargo-xwin for cross-platform builds targeting Windows + if (target.includes('windows') && process.platform !== 'win32') { + runCommand("cargo", ["install", "--version", "0.20.2", "--locked", "cargo-xwin"]); + // install tools needed for packaging Appx, only supported on macOS for now. + if (process.platform === "darwin") { + runCommand("brew", ["install", "iinuwa/msix-packaging-tap/msix-packaging", "osslsigncode"]); + } + } +} + +function effectivePlatform(target) { + if (target) { + return rustTargetsMap[target].platform + } + return process.platform } if (!crossPlatform && !target) { @@ -94,9 +138,9 @@ if (!crossPlatform && !target) { if (target) { console.log(`Building for target: ${target} in ${mode} mode`); installTarget(target); - buildNapiModule(target, mode === "release"); - buildProxyBin(target, mode === "release"); - buildImporterBinaries(false, mode === "release"); + buildNapiModule(target, isRelease); + buildProxyBin(target, isRelease); + buildImporterBinaries(target, isRelease); buildProcessIsolation(); return; } @@ -113,8 +157,8 @@ if (process.platform === "linux") { platformTargets.forEach(([target, _]) => { installTarget(target); - buildNapiModule(target, mode === "release"); - buildProxyBin(target, mode === "release"); - buildImporterBinaries(target, mode === "release"); + buildNapiModule(target, isRelease); + buildProxyBin(target, isRelease); + buildImporterBinaries(target, isRelease); buildProcessIsolation(); }); diff --git a/apps/desktop/desktop_native/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/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json index 0c95c7f01a6..3e1ca673c3c 100644 --- a/apps/desktop/electron-builder.beta.json +++ b/apps/desktop/electron-builder.beta.json @@ -1,4 +1,6 @@ { + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "extraMetadata": { "name": "bitwarden-beta" }, @@ -13,14 +15,15 @@ }, "afterSign": "scripts/after-sign.js", "afterPack": "scripts/after-pack.js", - "asarUnpack": ["**/*.node"], + "beforePack": "scripts/before-pack.js", "files": [ - "**/*", - "!**/node_modules/@bitwarden/desktop-napi/**/*", - "**/node_modules/@bitwarden/desktop-napi/index.js", - "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" + "!node_modules/@bitwarden/desktop-napi/scripts", + "!node_modules/@bitwarden/desktop-napi/src", + "!node_modules/@bitwarden/desktop-napi/Cargo.toml", + "!node_modules/@bitwarden/desktop-napi/build.rs", + "!node_modules/@bitwarden/desktop-napi/package.json" ], - "electronVersion": "36.8.1", + "electronVersion": "37.7.0", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", @@ -34,11 +37,11 @@ }, "extraFiles": [ { - "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", + "from": "desktop_native/dist/desktop_proxy.win32-${arch}.exe", "to": "desktop_proxy.exe" }, { - "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "from": "desktop_native/dist/bitwarden_chromium_import_helper.win32-${arch}.exe", "to": "bitwarden_chromium_import_helper.exe" } ] @@ -58,9 +61,10 @@ "appx": { "artifactName": "Bitwarden-Beta-${version}-${arch}.${ext}", "backgroundColor": "#175DDC", + "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "BitwardenBeta", "identityName": "8bitSolutionsLLC.BitwardenBeta", - "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", + "publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US", "publisherDisplayName": "Bitwarden Inc", "languages": [ "en-US", diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index a4e1c44dc5b..83bd2921551 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -1,4 +1,6 @@ { + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "extraMetadata": { "name": "bitwarden" }, @@ -13,12 +15,13 @@ }, "afterSign": "scripts/after-sign.js", "afterPack": "scripts/after-pack.js", - "asarUnpack": ["**/*.node"], + "beforePack": "scripts/before-pack.js", "files": [ - "**/*", - "!**/node_modules/@bitwarden/desktop-napi/**/*", - "**/node_modules/@bitwarden/desktop-napi/index.js", - "**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node" + "!node_modules/@bitwarden/desktop-napi/scripts", + "!node_modules/@bitwarden/desktop-napi/src", + "!node_modules/@bitwarden/desktop-napi/Cargo.toml", + "!node_modules/@bitwarden/desktop-napi/build.rs", + "!node_modules/@bitwarden/desktop-napi/package.json" ], "electronVersion": "39.2.6", "generateUpdatesFilesForAllChannels": true, @@ -94,11 +97,11 @@ }, "extraFiles": [ { - "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", + "from": "desktop_native/dist/desktop_proxy.win32-${arch}.exe", "to": "desktop_proxy.exe" }, { - "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "from": "desktop_native/dist/bitwarden_chromium_import_helper.win32-${arch}.exe", "to": "bitwarden_chromium_import_helper.exe" } ] @@ -172,9 +175,10 @@ "appx": { "artifactName": "${productName}-${version}-${arch}.${ext}", "backgroundColor": "#175DDC", + "customManifestPath": "./custom-appx-manifest.xml", "applicationId": "bitwardendesktop", "identityName": "8bitSolutionsLLC.bitwardendesktop", - "publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418", + "publisher": "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US", "publisherDisplayName": "Bitwarden Inc", "languages": [ "en-US", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 97ab8585a69..ad20e7c0e69 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -29,7 +29,7 @@ "build:macos-extension:mas": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas", "build:macos-extension:masdev": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js mas-dev", "build:main": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main", - "build:main:dev": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main", + "build:main:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main", "build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main --watch", "build:renderer": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer", "build:renderer:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer", @@ -40,16 +40,16 @@ "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", "pack:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never", "pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never", "pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"", - "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", - "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", + "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", + "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never", "pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", "dist:dir": "npm run build && npm run pack:dir", "dist:lin": "npm run build && npm run pack:lin", @@ -62,7 +62,7 @@ "publish:lin": "npm run build && npm run clean:dist && electron-builder --linux --x64 -p always", "publish:mac": "npm run build && npm run clean:dist && electron-builder --mac -p always", "publish:mac:mas": "npm run dist:mac:mas && npm run upload:mas", - "publish:win": "npm run build && npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p always -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", + "publish:win": "npm run build && npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p always", "publish:win:dev": "npm run build:dev && npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p always", "upload:mas": "xcrun altool --upload-app --type osx --file \"$(find ./dist/mas-universal/Bitwarden*.pkg)\" --apiKey $APP_STORE_CONNECT_AUTH_KEY --apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER", "test": "jest", diff --git a/apps/desktop/scripts/after-pack.js b/apps/desktop/scripts/after-pack.js index 5fc42f31ac3..34378ee092b 100644 --- a/apps/desktop/scripts/after-pack.js +++ b/apps/desktop/scripts/after-pack.js @@ -6,9 +6,12 @@ const path = require("path"); const { flipFuses, FuseVersion, FuseV1Options } = require("@electron/fuses"); const builder = require("electron-builder"); const fse = require("fs-extra"); - exports.default = run; +/** + * + * @param {builder.AfterPackContext} context + */ async function run(context) { console.log("## After pack"); // console.log(context); diff --git a/apps/desktop/scripts/appx-cross-build.ps1 b/apps/desktop/scripts/appx-cross-build.ps1 new file mode 100755 index 00000000000..62619d5ea37 --- /dev/null +++ b/apps/desktop/scripts/appx-cross-build.ps1 @@ -0,0 +1,226 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS +Script to build, package and sign the Bitwarden desktop client as a Windows Appx +package. + +.DESCRIPTION +This script provides cross-platform support for packaging and signing the +Bitwarden desktop client as a Windows Appx package. + +Currently, only macOS -> Windows Appx is supported, but Linux -> Windows Appx +could be added in the future by providing Linux binaries for the msix-packaging +project. + +.NOTES +The reason this script exists is because electron-builder does not currently +support cross-platform Appx packaging without proprietary tools (Parallels +Windows VM). This script uses third-party tools (makemsix from msix-packaging +and osslsigncode) to package and sign the Appx. + +The signing certificate must have the same subject as the publisher name. This +can be generated on the Windows target using PowerShell 5.1 and copied to the +host, or directly on the host with OpenSSL. + +Using Windows PowerShell 5.1: +```powershell +$publisher = "CN=Bitwarden Inc., O=Bitwarden Inc., L=Santa Barbara, S=California, C=US, SERIALNUMBER=7654941, OID.2.5.4.15=Private Organization, OID.1.3.6.1.4.1.311.60.2.1.2=Delaware, OID.1.3.6.1.4.1.311.60.2.1.3=US" +$certificate = New-SelfSignedCertificate -Type Custom -KeyUsage DigitalSignature -CertStoreLocation "Cert:\CurrentUser\My" -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") -Subject $publisher -FriendlyName "Bitwarden Developer Signing Certificate" +$password = Read-Host -AsSecureString +Export-PfxCertificate -cert "Cert:\CurrentUser\My\${$certificate.Thumbprint}" -FilePath "C:\path/to/pfx" -Password $password +``` + +Using OpenSSL: +```sh +subject="jurisdictionCountryName=US/jurisdictionStateOrProvinceName=Delaware/businessCategory=Private Organization/serialNumber=7654941, C=US, ST=California, L=Santa Barbara, O=Bitwarden Inc., CN=Bitwarden Inc." +keyfile="/tmp/mysigning.rsa.pem" +certfile="/tmp/mysigning.cert.pem" +p12file="/tmp/mysigning.p12" +openssl req -x509 -keyout "$keyfile" -out "$certfile" -subj "$subject" \ + -newkey rsa:2048 -days 3650 -nodes \ + -addext 'keyUsage=critical,digitalSignature' \ + -addext 'extendedKeyUsage=critical,codeSigning' \ + -addext 'basicConstraints=critical,CA:FALSE' +openssl pkcs12 -inkey "$keyfile" -in "$certfile" -export -out "$p12file" +rm $keyfile +``` + +.EXAMPLE +./scripts/cross-build.ps1 -Architecture arm64 -CertificatePath ~/Development/code-signing.pfx -CertificatePassword (Read-Host -AsSecureString) -Release -Beta + +Reads the signing certificate password from user input, then builds, packages +and signs the Appx. + +Alternatively, you can specify the CERTIFICATE_PASSWORD environment variable. +#> +param( + [Parameter(Mandatory=$true)] + [ValidateSet("X64", "ARM64")]$Architecture, + [string] + # Path to PKCS12 certificate file. If not specified, the Appx will not be signed. + $CertificatePath, + [SecureString] + # Password for PKCS12 certificate. Alternatively, may be specified in + # CERTIFICATE_PASSWORD environment variable. If not specified, the Appx will + # not be signed. + $CertificatePassword, + [Switch] + # Whether to build the Beta version of the app. + $Beta=$false, + [Switch] + # Whether to build in release mode. + $Release=$false +) +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true +$startTime = Get-Date +$originalLocation = Get-Location +if (!(Get-Command makemsix -ErrorAction SilentlyContinue)) { + Write-Error "The `makemsix` tool from the msix-packaging project is required to construct Appx package." + Write-Error "On macOS, you can install with Homebrew:" + Write-Error " brew install iinuwa/msix-packaging-tap/msix-packaging" + Exit 1 +} + +if (!(Get-Command osslsigncode -ErrorAction SilentlyContinue)) { + Write-Error "The `osslsigncode` tool is required to sign the Appx package." + Write-Error "On macOS, you can install with Homebrew:" + Write-Error " brew install osslsigncode" + Exit 1 +} + +if (!(Get-Command cargo-xwin -ErrorAction SilentlyContinue)) { + Write-Error "The `cargo-xwin` tool is required to cross-compile Windows native code." + Write-Error "You can install with cargo:" + Write-Error " cargo install --version 0.20.2 --locked cargo-xwin" + Exit 1 +} + +try { + +# Resolve certificate file before we change directories. +$CertificateFile = Get-Item $CertificatePath -ErrorAction SilentlyContinue + +cd $PSScriptRoot/.. + +if ($Beta) { + $electronConfigFile = Get-Item "./electron-builder.beta.json" +} +else { + $electronConfigFile = Get-Item "./electron-builder.json" +} + +$builderConfig = Get-Content $electronConfigFile | ConvertFrom-Json +$packageConfig = Get-Content package.json | ConvertFrom-Json +$manifestTemplate = Get-Content $builderConfig.appx.customManifestPath + +$srcDir = Get-Location +$assetsDir = Get-Item $builderConfig.directories.buildResources +$buildDir = Get-Item $builderConfig.directories.app +$outDir = Join-Path (Get-Location) ($builderConfig.directories.output ?? "dist") + +if ($Release) { + $buildConfiguration = "--release" +} +$arch = "$Architecture".ToLower() +$ext = "appx" +$version = Get-Date -Format "yyyy.M.d.1HHmm" +$productName = $builderConfig.productName +$artifactName = "${productName}-$($packageConfig.version)-${arch}.$ext" + +Write-Host "Building native code" +$rustTarget = switch ($arch) { + x64 { "x86_64-pc-windows-msvc" } + arm64 { "aarch64-pc-windows-msvc" } + default { + Write-Error "Unsupported architecture: $Architecture. Supported architectures are x64 and arm64" + Exit(1) + } +} +npm run build-native -- cross-platform $buildConfiguration "--target=$rustTarget" + +Write-Host "Building Javascript code" +if ($Release) { + npm run build +} +else { + npm run build:dev +} + +Write-Host "Cleaning output folder" +Remove-Item -Recurse -Force $outDir -ErrorAction Ignore + +Write-Host "Packaging Electron executable" +& npx electron-builder --config $electronConfigFile --publish never --dir --win --$arch + +cd $outDir +New-Item -Type Directory (Join-Path $outDir "appx") + +Write-Host "Building Appx directory structure" +$appxDir = (Join-Path $outDir appx/app) +if ($arch -eq "x64") { + Move-Item (Join-Path $outDir "win-unpacked") $appxDir +} +else { + Move-Item (Join-Path $outDir "win-${arch}-unpacked") $appxDir +} + +Write-Host "Copying Assets" +New-Item -Type Directory (Join-Path $outDir appx/assets) +Copy-Item $srcDir/resources/appx/* $outDir/appx/assets/ + +Write-Host "Building Appx manifest" +$translationMap = @{ + 'arch' = $arch + 'applicationId' = $builderConfig.appx.applicationId + 'displayName' = $productName + 'executable' = "app\${productName}.exe" + 'publisher' = $builderConfig.appx.publisher + 'publisherDisplayName' = $builderConfig.appx.publisherDisplayName + 'version' = $version +} + +$manifest = $manifestTemplate +$translationMap.Keys | ForEach-Object { + $manifest = $manifest.Replace("`${$_}", $translationMap[$_]) +} +$manifest | Out-File appx/AppxManifest.xml +$unsignedArtifactpath = [System.IO.Path]::GetFileNameWithoutExtension($artifactName) + "-unsigned.$ext" +Write-Host "Creating unsigned Appx" +makemsix pack -d appx -p $unsignedArtifactpath + +$outfile = Join-Path $outDir $unsignedArtifactPath +if ($null -eq $CertificatePath) { + Write-Warning "No Certificate specified. Not signing Appx." +} +elseif ($null -eq $CertificatePassword -and $null -eq $env:CERTIFICATE_PASSWORD) { + Write-Warning "No certificate password specified in CertificatePassword argument nor CERTIFICATE_PASSWORD environment variable. Not signing Appx." +} +else { + $cert = $CertificateFile + $pw = $null + if ($null -ne $CertificatePassword) { + $pw = ConvertFrom-SecureString -SecureString $CertificatePassword -AsPlainText + } else { + $pw = $env:CERTIFICATE_PASSWORD + } + $unsigned = $outfile + $outfile = (Join-Path $outDir $artifactName) + Write-Host "Signing $artifactName with $cert" + osslsigncode sign ` + -pkcs12 "$cert" ` + -pass "$pw" ` + -in $unsigned ` + -out $outfile + Remove-Item $unsigned +} + +$endTime = Get-Date +$elapsed = $endTime - $startTime +Write-Host "Successfully packaged $(Get-Item $outfile)" +Write-Host ("Finished at $($endTime.ToString('HH:mm:ss')) in $($elapsed.ToString('mm')) minutes and $($elapsed.ToString('ss')).$($elapsed.ToString('fff')) seconds") +} +finally { + Set-Location -Path $originalLocation +} diff --git a/apps/desktop/scripts/before-pack.js b/apps/desktop/scripts/before-pack.js new file mode 100644 index 00000000000..ca9bf924b2d --- /dev/null +++ b/apps/desktop/scripts/before-pack.js @@ -0,0 +1,31 @@ +/* eslint-disable no-console */ +/** @import { BeforePackContext } from 'app-builder-lib' */ +exports.default = run; + +/** + * @param {BeforePackContext} context + */ +async function run(context) { + console.log("## before pack"); + console.log("Stripping .node files that don't belong to this platform..."); + removeExtraNodeFiles(context); +} + +/** + * Removes Node files for platforms besides the current platform being packaged. + * + * @param {BeforePackContext} context + */ +function removeExtraNodeFiles(context) { + // When doing cross-platform builds, due to electron-builder limitiations, + // .node files for other platforms may be generated and unpacked, so we + // remove them manually here before signing and distributing. + const packagerPlatform = context.packager.platform.nodeName; + const platforms = ["darwin", "linux", "win32"]; + const fileFilter = context.packager.info._configuration.files[0].filter; + for (const platform of platforms) { + if (platform != packagerPlatform) { + fileFilter.push(`!node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-*.node`); + } + } +} diff --git a/apps/desktop/sign.js b/apps/desktop/sign.js index 6a42666c46f..f115e9b8097 100644 --- a/apps/desktop/sign.js +++ b/apps/desktop/sign.js @@ -1,22 +1,60 @@ /* eslint-disable @typescript-eslint/no-require-imports, no-console */ +const child_process = require("child_process"); exports.default = async function (configuration) { - if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && configuration.path.slice(-4) == ".exe") { + const ext = configuration.path.split(".").at(-1); + if (parseInt(process.env.ELECTRON_BUILDER_SIGN) === 1 && ["exe", "appx"].includes(ext)) { console.log(`[*] Signing file: ${configuration.path}`); - require("child_process").execSync( - `azuresigntool sign -v ` + - `-kvu ${process.env.SIGNING_VAULT_URL} ` + - `-kvi ${process.env.SIGNING_CLIENT_ID} ` + - `-kvt ${process.env.SIGNING_TENANT_ID} ` + - `-kvs ${process.env.SIGNING_CLIENT_SECRET} ` + - `-kvc ${process.env.SIGNING_CERT_NAME} ` + - `-fd ${configuration.hash} ` + - `-du ${configuration.site} ` + - `-tr http://timestamp.digicert.com ` + - `"${configuration.path}"`, + child_process.execFileSync( + "azuresigntool", + // prettier-ignore + [ + "sign", + "-v", + "-kvu", process.env.SIGNING_VAULT_URL, + "-kvi", process.env.SIGNING_CLIENT_ID, + "-kvt", process.env.SIGNING_TENANT_ID, + "-kvs", process.env.SIGNING_CLIENT_SECRET, + "-kvc", process.env.SIGNING_CERT_NAME, + "-fd", configuration.hash, + "-du", configuration.site, + "-tr", "http://timestamp.digicert.com", + configuration.path, + ], { stdio: "inherit", }, ); + } else if (process.env.ELECTRON_BUILDER_SIGN_CERT && ["exe", "appx"].includes(ext)) { + console.log(`[*] Signing file: ${configuration.path}`); + if (process.platform !== "win32") { + console.warn( + "Signing Windows executables on non-Windows platforms is not supported. Not signing.", + ); + return; + } + const certFile = process.env.ELECTRON_BUILDER_SIGN_CERT; + const certPw = process.env.ELECTRON_BUILDER_SIGN_CERT_PW; + if (!certPw) { + throw new Error( + "The certificate file password must be set in ELECTRON_BUILDER_SIGN_CERT_PW in order to sign files.", + ); + } + try { + child_process.execFileSync( + "signtool.exe", + ["sign", "/fd", "SHA256", "/a", "/f", certFile, "/p", certPw, configuration.path], + { + stdio: "inherit", + }, + ); + console.info(`Signed ${configuration.path} successfully.`); + } catch (error) { + throw new Error( + `Failed to sign ${configuration.path}: ${error.message}\n` + + `Check that ELECTRON_BUILDER_SIGN_CERT points to a valid PKCS12 file ` + + `and ELECTRON_BUILDER_SIGN_CERT_PW is correct.`, + ); + } } }; diff --git a/apps/desktop/src/app/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..e9b6dfdc9e5 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -114,6 +114,8 @@ const routes: Routes = [ authGuard, canAccessFeature(FeatureFlag.DesktopUiMigrationMilestone1, false, "new-vault", false), ], + // Needed to ensure feature flag changes are picked up on account switching + runGuardsAndResolvers: "always", }, { path: "send", @@ -361,6 +363,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..01eb8c728e5 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(); @@ -482,9 +492,8 @@ export class AppComponent implements OnInit, OnDestroy { this.loading = true; await this.syncService.fullSync(false); this.loading = false; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["vault"]); + // Force reload to ensure route guards are activated + await this.router.navigate(["vault"], { onSameUrlNavigation: "reload" }); } this.messagingService.send("finishSwitchAccount"); break; @@ -499,13 +508,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 +914,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-filters-nav.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts index ab881e5b57b..f22b94974d1 100644 --- a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts @@ -6,7 +6,7 @@ import { BehaviorSubject } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NavigationModule } from "@bitwarden/components"; import { SendListFiltersService } from "@bitwarden/send-ui"; import { GlobalStateProvider } from "@bitwarden/state"; diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts index 28004f475e5..0dfdc1ee7c5 100644 --- a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts @@ -4,7 +4,7 @@ import { toSignal } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; import { filter, map, startWith } from "rxjs"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NavigationModule } from "@bitwarden/components"; import { SendListFiltersService } from "@bitwarden/send-ui"; import { I18nPipe } from "@bitwarden/ui-common"; diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.html b/apps/desktop/src/app/tools/send-v2/send-v2.component.html index 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..cb186f31d35 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" }, @@ -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" }, @@ -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..a33c6b301b6 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" }, @@ -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" }, @@ -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..4562253e12c 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/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 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": "\"Send\" keçidini kopyala", + "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": "Heç bir axtarış nəticəsi qayıtmadı" + }, "sendsBodyNoItems": { "message": "İstənilən platformada faylları və veriləri hər kəslə güvənli şəkildə paylaşın. Məlumatlarınız, ifşa olunmamaq üçün ucdan-uca şifrələnmiş qalacaq.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "sendsBodyNoSearchResults": { + "message": "Filtrləri təmizləyin və ya başqa bir axtarış terminini sınayın" + }, "generatorNudgeTitle": { "message": "Cəld parol yaradın" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Yazma qısayolu" }, - "editAutotypeShortcutDescription": { - "message": "Aşağıdakı dəyişdiricilərdən birini və ya ikisini daxil edin: Ctrl, Alt, Win və ya Shift və bir hərf." + "editAutotypeKeyboardModifiersDescription": { + "message": "Aşağıdakı dəyişdiricilərdən birini və ya ikisini daxil edin: Ctrl, Alt, Win və bir hərf." }, "invalidShortcut": { "message": "Yararsız qısayol" @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Arxivdən çıxart" }, + "archived": { + "message": "Arxivləndi" + }, "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": "Arxivdən çıxart və saxla" + }, + "restartPremium": { + "message": "\"Premium\"u yenidən başlat" + }, + "premiumSubscriptionEnded": { + "message": "Premium abunəliyiniz bitdi" + }, + "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": "Element bərpa edildi" + }, "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..4f441d20781 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" }, @@ -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" }, @@ -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..10702ea4aa9 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": "Създавайте пароли бързо" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Комбинация за въвеждане" }, - "editAutotypeShortcutDescription": { - "message": "Използвайте един или повече от модификаторите Ctrl, Alt, Win или Shift, заедно с някоя буква." + "editAutotypeKeyboardModifiersDescription": { + "message": "Използвайте един или повече от модификаторите Ctrl, Alt или Win, заедно с някоя буква." }, "invalidShortcut": { "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..607418bcb46 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" }, @@ -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" }, @@ -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..9f13b809760 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" }, @@ -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" }, @@ -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..1b03ad6fa1e 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" }, @@ -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" }, @@ -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..437f42f840f 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" }, @@ -4253,7 +4267,7 @@ "typeShortcut": { "message": "Napsat zkratku" }, - "editAutotypeShortcutDescription": { + "editAutotypeKeyboardModifiersDescription": { "message": "Zahrňte jeden nebo dva z následujících modifikátorů: Ctrl, Alt, Win nebo Shift a písmeno." }, "invalidShortcut": { @@ -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..f04f6625529 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" }, @@ -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" }, @@ -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..f022c4cee33 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" }, @@ -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" }, @@ -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..2783b39ca69 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" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Autotype-Tastaturkürzel" }, - "editAutotypeShortcutDescription": { - "message": "Füge einen oder zwei der folgenden Modifikatoren ein: Strg, Alt, Win oder Umschalttaste, sowie einen Buchstaben." + "editAutotypeKeyboardModifiersDescription": { + "message": "Füge einen oder zwei der folgenden Modifikatoren ein: Strg, Alt, Win und einen Buchstaben." }, "invalidShortcut": { "message": "Ungültiges Tastaturkürzel" @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Wiederherstellen" }, + "archived": { + "message": "Archiviert" + }, "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": "Nicht mehr archivieren und speichern" + }, + "restartPremium": { + "message": "Premium neu starten" + }, + "premiumSubscriptionEnded": { + "message": "Dein Premium-Abonnement ist abgelaufen" + }, + "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": "Eintrag wurde wiederhergestellt" + }, "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..9a4d2b736be 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": "Γρήγορη δημιουργία κωδικών πρόσβασης" }, @@ -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" }, @@ -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..33de901c06b 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." @@ -4002,6 +4010,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" @@ -4031,10 +4045,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 +4273,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 +4312,9 @@ "unArchive": { "message": "Unarchive" }, + "archived": { + "message": "Archived" + }, "itemsInArchive": { "message": "Items in archive" }, @@ -4313,6 +4336,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 +4499,7 @@ "placeholders": { "organization": { "content": "$1", - "example": "My Org Name" + "example": "My Org Name" } } }, @@ -4470,7 +4508,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..f7020c63bf1 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" }, @@ -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": "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..d4e497f41d3 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" }, @@ -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": "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..ab07b9db9af 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" }, @@ -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" }, @@ -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..af61f7a97a0 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" }, @@ -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": "Atajo inválido" @@ -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..b013a55ffd7 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" }, @@ -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" }, @@ -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..49b7bad76de 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" }, @@ -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" }, @@ -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..10be871914f 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": "ساخت سریع کلمات عبور" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "تایپ میانبر" }, - "editAutotypeShortcutDescription": { - "message": "شامل یک یا دو مورد از کلیدهای تغییردهنده زیر: Ctrl، Alt، Win یا Shift و یک حرف." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "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..2c2275f3afa 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" }, @@ -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" }, @@ -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..ff7562680e1 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" }, @@ -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" }, @@ -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..566e6fc7429 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" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Saisir le raccourci" }, - "editAutotypeShortcutDescription": { - "message": "Inclure un ou deux des modificateurs suivants : Ctrl, Alt, Win, ou Shift, et une lettre." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Raccourci invalide" @@ -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..5a5a1715d7e 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" }, @@ -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" }, @@ -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..5ce8db992f2 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": "צור סיסמאות במהירות" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "הקלד קיצור דרך" }, - "editAutotypeShortcutDescription": { - "message": "כלול אחד או שניים ממקשי הצירוף הבאים: Ctrl, Alt, Win, או Shift, ואות." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "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..4c376c957d5 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" }, @@ -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" }, @@ -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..71aa2a5a67a 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" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Vrsta prečaca" }, - "editAutotypeShortcutDescription": { - "message": "Uključi jedan ili dva modifikatora: Ctrl, Alt, Win ili Shift i slovo." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Nevažeći prečac" @@ -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..fd0667d7aa8 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" }, @@ -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": "Tartalmazzon egyet vagy kettőt a következő módosítók közül: Ctrl, Alt, Win és egy betű." }, "invalidShortcut": { "message": "Invalid shortcut" @@ -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..eb84f8bd747 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" }, @@ -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" }, @@ -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..e4492d051b3 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -100,6 +100,10 @@ } } }, + "new": { + "message": "Nuovo", + "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": "Copia link del Send", + "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": "Nessun risultato" + }, "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": "Elimina i filtri di ricerca o prova con altri termini" + }, "generatorNudgeTitle": { "message": "Crea rapidamente password sicure" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Premi i tasti da impostare per la scorciatoia" }, - "editAutotypeShortcutDescription": { - "message": "Includi uno o due dei seguenti modificatori: Ctrl, Alt, Win, o Shift, più una lettera." + "editAutotypeKeyboardModifiersDescription": { + "message": "Includi uno o due tasti modificatori a scelta tra Ctrl, Alt e Win." }, "invalidShortcut": { "message": "Scorciatoia non valida" @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Rimuovi dall'Archivio" }, + "archived": { + "message": "Archiviato" + }, "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": "Togli dall'archivio e salva" + }, + "restartPremium": { + "message": "Riavvia Premium" + }, + "premiumSubscriptionEnded": { + "message": "Il tuo abbonamento Premium è terminato" + }, + "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": "L'elemento è stato ripristinato" + }, "zipPostalCodeLabel": { "message": "CAP / codice postale" }, @@ -4388,6 +4420,9 @@ "sessionTimeoutHeader": { "message": "Timeout della sessione" }, + "resizeSideNavigation": { + "message": "Ridimensiona la navigazione laterale" + }, "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..24395bf30d7 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" }, @@ -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" }, @@ -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..831367b12a8 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" }, @@ -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" }, @@ -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..5a5a1715d7e 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" }, @@ -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" }, @@ -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..d7602b05af2 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" }, @@ -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" }, @@ -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..700439e6030 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" }, @@ -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" }, @@ -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..e959141e0c9 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" }, @@ -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" }, @@ -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..12e548f50b9 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" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Ievadīt īsinājumtaustiņus" }, - "editAutotypeShortcutDescription": { - "message": "Jāiekļauj viens vai divi no šiem taustiņiem - Ctrl, Alt, Win vai Shift - un burts." + "editAutotypeKeyboardModifiersDescription": { + "message": "Jāiekļauj viens vai divi no šiem taustiņiem: Ctrl, Alt, Win un burts." }, "invalidShortcut": { "message": "Nederīgi īsinājumtaustiņi" @@ -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..fea8b6d97a5 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" }, @@ -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" }, @@ -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..f8eb1f682f9 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" }, @@ -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" }, @@ -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..5a5a1715d7e 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" }, @@ -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" }, @@ -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..189044c4c40 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" }, @@ -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" }, @@ -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..508e4bbdfbc 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" }, @@ -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" }, @@ -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..8d688575099 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" }, @@ -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" }, @@ -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..bfbac0c2e65 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" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Typ de sneltoets" }, - "editAutotypeShortcutDescription": { - "message": "Voeg een of twee van de volgende toetsen toe: Ctrl, Alt, Win of Shift, en een letter." + "editAutotypeKeyboardModifiersDescription": { + "message": "Voeg een of twee van de volgende toetsen toe: Ctrl, Alt, Win en een letter." }, "invalidShortcut": { "message": "Ongeldige sneltoets" @@ -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..9119a018cbd 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" }, @@ -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" }, @@ -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..4ffca3c1e6e 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" }, @@ -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" }, @@ -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..50a39b462d9 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ł" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Rodzaj skrótu" }, - "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": "Skrót jest nieprawidłowy" @@ -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..a0696b63c1e 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" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Atalho de digitação" }, - "editAutotypeShortcutDescription": { - "message": "Inclua um ou dois dos seguintes modificadores: Ctrl, Alt, Win, ou Shift, e uma letra." + "editAutotypeKeyboardModifiersDescription": { + "message": "Inclua um ou dois dos seguintes modificadores: Ctrl, Alt, Win, e uma letra." }, "invalidShortcut": { "message": "Atalho inválido" @@ -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..3b6a818977e 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" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Introduzir atalho" }, - "editAutotypeShortcutDescription": { - "message": "Inclua um ou dois dos seguintes modificadores: Ctrl, Alt, Win, ou Shift, e uma letra." + "editAutotypeKeyboardModifiersDescription": { + "message": "Inclua um ou dois dos seguintes modificadores: Ctrl, Alt, Win e uma letra." }, "invalidShortcut": { "message": "Atalho inválido" @@ -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..b188aa96d1c 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" }, @@ -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" }, @@ -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..6d7b8bf1c23 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": "Быстрое создание паролей" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Введите сочетание клавиш" }, - "editAutotypeShortcutDescription": { - "message": "Включите один или два из следующих модификаторов: Ctrl, Alt, Win или Shift и букву." + "editAutotypeKeyboardModifiersDescription": { + "message": "Включите один или два из следующих модификаторов: Ctrl, Alt, Win и букву." }, "invalidShortcut": { "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..37f2897c919 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" }, @@ -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" }, @@ -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..b3398629da3 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" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Zadajte klávesovú skratku" }, - "editAutotypeShortcutDescription": { - "message": "Použite jeden alebo dva z nasledujúcich modifikátorov: Ctrl, Alt, Win, alebo Shift a písmeno." + "editAutotypeKeyboardModifiersDescription": { + "message": "Použite jeden alebo dva z nasledujúcich modifikátorov: Ctrl, Alt, Win a písmeno." }, "invalidShortcut": { "message": "Neplatná klávesová skratka" @@ -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..6fa6d25285f 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" }, @@ -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" }, @@ -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..885ed8410db 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": "Брзо креирајте лозинке" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Унети пречицу" }, - "editAutotypeShortcutDescription": { - "message": "Укључите један или два следећа модификатора: Ctrl, Alt, Win, или Shift, и слово." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "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..8c61f598efe 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" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Inmatningsgenväg" }, - "editAutotypeShortcutDescription": { - "message": "Inkludera en eller två av följande modifierare: Ctrl, Alt, Win, eller Skift och en bokstav." + "editAutotypeKeyboardModifiersDescription": { + "message": "Inkludera en eller två av följande modifierare: Ctrl, Alt, Win och en bokstav." }, "invalidShortcut": { "message": "Ogiltig genväg" @@ -4292,6 +4306,9 @@ "unArchive": { "message": "Avarkivera" }, + "archived": { + "message": "Arkiverade" + }, "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": "Avarkivera och spara" + }, + "restartPremium": { + "message": "Starta om Premium" + }, + "premiumSubscriptionEnded": { + "message": "Ditt Premium-abonnemang avslutades" + }, + "premiumSubscriptionEndedDesc": { + "message": "För att återfå åtkomst till ditt arkiv, starta om Premium-abonnemanget. Om du redigerar detaljer för ett arkiverat objekt innan du startar om kommer det att flyttas tillbaka till ditt valv." + }, + "itemRestored": { + "message": "Objektet har återställts" + }, "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..d95440ca1a2 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": "கடவுச்சொற்களை விரைவாக உருவாக்கவும்" }, @@ -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" }, @@ -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..5a5a1715d7e 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" }, @@ -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" }, @@ -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..b87ed307efc 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" }, @@ -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" }, @@ -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..59995a02096 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" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Kısayolu yazın" }, - "editAutotypeShortcutDescription": { - "message": "Aşağıdaki değiştirici tuşlardan birini veya ikisini (Ctrl, Alt, Win ya da Shift) ve bir harf kullanın." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Geçersiz kısayol" @@ -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..ed9c76b6ecd 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": "Швидко створюйте паролі" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Введіть комбінацію клавіш" }, - "editAutotypeShortcutDescription": { - "message": "Використайте один або два таких модифікацій: Ctrl, Alt, Win, Shift, і літеру." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "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..ff711c154bc 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" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "Phím tắt nhập liệu" }, - "editAutotypeShortcutDescription": { - "message": "Bao gồm một hoặc hai trong số các phím bổ trợ sau: Ctrl, Alt, Win hoặc Shift, và một chữ cái." + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "message": "Phím tắt không hợp lệ" @@ -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..5adc841351f 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." @@ -2566,7 +2574,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为「$ACTION$」。", "placeholders": { "hours": { "content": "$1", @@ -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": "快速创建密码" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "输入快捷键" }, - "editAutotypeShortcutDescription": { - "message": "包含以下一个或两个修饰符:Ctrl、Alt、Win 或 Shift,外加一个字母。" + "editAutotypeKeyboardModifiersDescription": { + "message": "包含以下修饰键中的一个或两个:Ctrl、Alt、Win,以及一个字母。" }, "invalidShortcut": { "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..7a3ff01e115 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": "快速建立密碼" }, @@ -4253,8 +4267,8 @@ "typeShortcut": { "message": "輸入快捷鍵" }, - "editAutotypeShortcutDescription": { - "message": "請包含以下修飾鍵之一或兩個:Ctrl、Alt、Win 或 Shift,再加上一個字母。" + "editAutotypeKeyboardModifiersDescription": { + "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, and a letter." }, "invalidShortcut": { "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/main/window.main.ts b/apps/desktop/src/main/window.main.ts index bbdd2ad0a0f..b2008d57bcd 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -4,7 +4,7 @@ import { once } from "node:events"; import * as path from "path"; import * as url from "url"; -import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, nativeTheme, screen, session } from "electron"; import { concatMap, firstValueFrom, pairwise } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -122,6 +122,7 @@ export class WindowMain { if (!isMacAppStore()) { const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { + dialog.showErrorBox("Error", "An instance of Bitwarden Desktop is already running."); app.quit(); return; } else { diff --git a/apps/desktop/src/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()) { +
+ + +
+ {{ "premiumSubscriptionEndedDesc" | i18n }} +
+ + {{ "restartPremium" | i18n }} + +
+
+
+ } +
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 9b8c54cb580..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 { 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 { UnsignedSharedKey } from "@bitwarden/sdk-internal"; 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 = "encrypted-key-data" as UnsignedSharedKey; - 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 8089e64aeb5..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; - 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/clients/account-billing.client.ts b/apps/web/src/app/billing/clients/account-billing.client.ts index 256a06b3ead..e520e70bf70 100644 --- a/apps/web/src/app/billing/clients/account-billing.client.ts +++ b/apps/web/src/app/billing/clients/account-billing.client.ts @@ -1,6 +1,8 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response"; +import { BitwardenSubscription } from "@bitwarden/subscription"; import { BillingAddress, @@ -11,13 +13,22 @@ import { @Injectable() export class AccountBillingClient { private endpoint = "/account/billing/vnext"; - private apiService: ApiService; - constructor(apiService: ApiService) { - this.apiService = apiService; - } + constructor(private apiService: ApiService) {} - purchasePremiumSubscription = async ( + getLicense = async (): Promise => { + const path = `${this.endpoint}/license`; + return this.apiService.send("GET", path, null, true, true); + }; + + getSubscription = async (): Promise => { + const path = `${this.endpoint}/subscription`; + const json = await this.apiService.send("GET", path, null, true, true); + const response = new BitwardenSubscriptionResponse(json); + return response.toDomain(); + }; + + purchaseSubscription = async ( paymentMethod: TokenizedPaymentMethod | NonTokenizedPaymentMethod, billingAddress: Pick, ): Promise => { @@ -29,6 +40,17 @@ export class AccountBillingClient { const request = isTokenizedPayment ? { tokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress } : { nonTokenizedPaymentMethod: paymentMethod, billingAddress: billingAddress }; + await this.apiService.send("POST", path, request, true, true); }; + + reinstateSubscription = async (): Promise => { + const path = `${this.endpoint}/subscription/reinstate`; + await this.apiService.send("POST", path, null, true, false); + }; + + updateSubscriptionStorage = async (additionalStorageGb: number): Promise => { + const path = `${this.endpoint}/subscription/storage`; + await this.apiService.send("PUT", path, { additionalStorageGb }, true, false); + }; } diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index fbaf65d1839..f85dab54fe7 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -1,9 +1,12 @@ import { inject, NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component"; import { SelfHostedPremiumComponent } from "@bitwarden/web-vault/app/billing/individual/premium/self-hosted-premium.component"; +import { AccountSubscriptionComponent } from "@bitwarden/web-vault/app/billing/individual/subscription/account-subscription.component"; import { BillingHistoryViewComponent } from "./billing-history-view.component"; import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component"; @@ -17,11 +20,15 @@ const routes: Routes = [ data: { titleId: "subscription" }, children: [ { path: "", pathMatch: "full", redirectTo: "premium" }, - { - path: "user-subscription", - component: UserSubscriptionComponent, - data: { titleId: "premiumMembership" }, - }, + ...featureFlaggedRoute({ + defaultComponent: UserSubscriptionComponent, + flaggedComponent: AccountSubscriptionComponent, + featureFlag: FeatureFlag.PM29594_UpdateIndividualSubscriptionPage, + routeOptions: { + path: "user-subscription", + data: { titleId: "premiumMembership" }, + }, + }), /** * Two-Route Matching Strategy for /premium: * diff --git a/apps/web/src/app/billing/individual/subscription/account-subscription.component.html b/apps/web/src/app/billing/individual/subscription/account-subscription.component.html new file mode 100644 index 00000000000..9bb788c1f36 --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/account-subscription.component.html @@ -0,0 +1,50 @@ +@if (subscriptionLoading()) { + + + {{ "loading" | i18n }} + +} @else { + @if (subscription.value(); as subscription) { + +
+

{{ "youHavePremium" | i18n }}

+

+ {{ "viewAndManagePremiumSubscription" | i18n }} +

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

{{ content.body }}

+
+ + {{ content.label }} + + @if (action() === "add") { + + + {{ "total" | i18n }} + {{ formGroup.value.amount }} GB × {{ price() | currency: "$" }} = + {{ price() * formGroup.value.amount | currency: "$" }} / + {{ term() | i18n }} + + } + +
+
+ + + + +
+
diff --git a/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts new file mode 100644 index 00000000000..f1350cda49e --- /dev/null +++ b/apps/web/src/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component.ts @@ -0,0 +1,182 @@ +import { CurrencyPipe } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; + +import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + AsyncActionsModule, + ButtonModule, + DIALOG_DATA, + DialogConfig, + DialogModule, + DialogRef, + DialogService, + FormFieldModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { Maybe } from "@bitwarden/pricing"; +import { MAX_STORAGE_GB } from "@bitwarden/subscription"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { AccountBillingClient } from "@bitwarden/web-vault/app/billing/clients"; + +type RemoveStorage = { + type: "remove"; + existing: number; +}; + +type AddStorage = { + type: "add"; + price: number; + provided: number; + cadence: SubscriptionCadence; + existing?: number; +}; + +export type AdjustAccountSubscriptionStorageDialogParams = RemoveStorage | AddStorage; + +type AdjustAccountSubscriptionStorageDialogResult = "closed" | "submitted"; + +@Component({ + templateUrl: "./adjust-account-subscription-storage-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [AccountBillingClient], + imports: [ + AsyncActionsModule, + ButtonModule, + CurrencyPipe, + DialogModule, + FormFieldModule, + I18nPipe, + ReactiveFormsModule, + TypographyModule, + ], +}) +export class AdjustAccountSubscriptionStorageDialogComponent { + private readonly accountBillingClient = inject(AccountBillingClient); + private readonly dialogParams = inject(DIALOG_DATA); + private readonly dialogRef = inject(DialogRef); + private readonly i18nService = inject(I18nService); + private readonly toastService = inject(ToastService); + + readonly action = computed<"add" | "remove">(() => this.dialogParams.type); + + readonly price = computed>(() => { + if (this.dialogParams.type === "add") { + return this.dialogParams.price; + } + }); + + readonly provided = computed>(() => { + if (this.dialogParams.type === "add") { + return this.dialogParams.provided; + } + }); + + readonly term = computed>(() => { + if (this.dialogParams.type === "add") { + switch (this.dialogParams.cadence) { + case "annually": + return this.i18nService.t("year"); + case "monthly": + return this.i18nService.t("month"); + } + } + }); + + readonly existing = computed>(() => this.dialogParams.existing); + + readonly content = computed<{ + title: string; + body: string; + label: string; + }>(() => { + const action = this.action(); + switch (action) { + case "add": + return { + title: this.i18nService.t("addStorage"), + body: this.i18nService.t("storageAddNote"), + label: this.i18nService.t("gbStorageAdd"), + }; + case "remove": + return { + title: this.i18nService.t("removeStorage"), + body: this.i18nService.t("whenYouRemoveStorage"), + label: this.i18nService.t("gbStorageRemove"), + }; + } + }); + + readonly maxPurchasable = computed>(() => { + const provided = this.provided(); + if (provided) { + return MAX_STORAGE_GB - provided; + } + }); + + readonly maxValidatorValue = computed(() => { + const maxPurchasable = this.maxPurchasable() ?? MAX_STORAGE_GB; + const existing = this.existing(); + const action = this.action(); + + switch (action) { + case "add": { + return existing ? maxPurchasable - existing : maxPurchasable; + } + case "remove": { + return existing ? existing : 0; + } + } + }); + + formGroup = new FormGroup({ + amount: new FormControl(1, { + nonNullable: true, + validators: [ + Validators.required, + Validators.min(1), + Validators.max(this.maxValidatorValue()), + ], + }), + }); + + submit = async () => { + this.formGroup.markAllAsTouched(); + if (!this.formGroup.valid || !this.formGroup.value.amount) { + return; + } + + const action = this.action(); + const existing = this.existing(); + const amount = this.formGroup.value.amount; + + switch (action) { + case "add": { + await this.accountBillingClient.updateSubscriptionStorage(amount + (existing ?? 0)); + break; + } + case "remove": { + await this.accountBillingClient.updateSubscriptionStorage(existing! - amount); + } + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("adjustedStorage", amount), + }); + + this.dialogRef.close("submitted"); + }; + + static open = ( + dialogService: DialogService, + dialogConfig: DialogConfig, + ) => + dialogService.open( + AdjustAccountSubscriptionStorageDialogComponent, + dialogConfig, + ); +} diff --git a/apps/web/src/app/billing/individual/upgrade/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/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 81169d719b6..83440646b48 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -478,13 +478,13 @@ describe("UpgradePaymentService", () => { describe("upgradeToPremium", () => { it("should call accountBillingClient to purchase premium subscription and refresh data", async () => { // Arrange - mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + mockAccountBillingClient.purchaseSubscription.mockResolvedValue(); // Act await sut.upgradeToPremium(mockTokenizedPaymentMethod, mockBillingAddress); // Assert - expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + expect(mockAccountBillingClient.purchaseSubscription).toHaveBeenCalledWith( mockTokenizedPaymentMethod, mockBillingAddress, ); @@ -496,13 +496,13 @@ describe("UpgradePaymentService", () => { const accountCreditPaymentMethod: NonTokenizedPaymentMethod = { type: NonTokenizablePaymentMethods.accountCredit, }; - mockAccountBillingClient.purchasePremiumSubscription.mockResolvedValue(); + mockAccountBillingClient.purchaseSubscription.mockResolvedValue(); // Act await sut.upgradeToPremium(accountCreditPaymentMethod, mockBillingAddress); // Assert - expect(mockAccountBillingClient.purchasePremiumSubscription).toHaveBeenCalledWith( + expect(mockAccountBillingClient.purchaseSubscription).toHaveBeenCalledWith( accountCreditPaymentMethod, mockBillingAddress, ); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index ae18ab4c629..b8d5637e471 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -143,7 +143,7 @@ export class UpgradePaymentService { ): Promise { this.validatePaymentAndBillingInfo(paymentMethod, billingAddress); - await this.accountBillingClient.purchasePremiumSubscription(paymentMethod, billingAddress); + await this.accountBillingClient.purchaseSubscription(paymentMethod, billingAddress); await this.refreshAndSync(); } diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.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..77ae3b31837 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -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: { translationKey: "", cost: 0, quantity: 0 }, + }, + cadence: "annually", + estimatedTax: 0, + }; + } + + return { + passwordManager: { + seats: { + translationKey: 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..f060d29b377 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -40,13 +40,13 @@

{{ "status" | i18n }}
-
+
{{ (subscription && subscriptionStatus) || "-" }} {{ "pendingCancellation" | i18n }}
@@ -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..5034b21d03d 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, @@ -30,6 +30,7 @@ import { import { UpdateLicenseDialogComponent } from "../shared/update-license-dialog.component"; import { UpdateLicenseDialogResult } from "../shared/update-license-types"; +// TODO: Remove with deletion of pm-29594-update-individual-subscription-page // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -251,15 +252,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, value: discount.amountOff } + : { type: DiscountTypes.PercentOff, 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/key-management/data-recovery/steps/cipher-step.spec.ts b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts index 9ae0600fb2a..7d6ad41cb00 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.spec.ts @@ -122,6 +122,41 @@ describe("CipherStep", () => { expect(logger.record).toHaveBeenCalledWith("Cipher ID cipher-3 was undecryptable"); expect(logger.record).toHaveBeenCalledWith("Found 2 undecryptable ciphers"); }); + + it("returns correct results when running diagnostics multiple times", async () => { + const userId = "user-id" as UserId; + const cipher1 = { id: "cipher-1", organizationId: null } as Cipher; + const cipher2 = { id: "cipher-2", organizationId: null } as Cipher; + + const workingData: RecoveryWorkingData = { + userId, + userKey: null, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [cipher1, cipher2], + folders: [], + }; + + // First run: cipher1 succeeds, cipher2 fails + cipherEncryptionService.decrypt + .mockResolvedValueOnce({} as any) + .mockRejectedValueOnce(new Error("Decryption failed")); + + const result1 = await cipherStep.runDiagnostics(workingData, logger); + + expect(result1).toBe(false); + expect(cipherStep.canRecover(workingData)).toBe(true); + + // Second run: all ciphers succeed + cipherEncryptionService.decrypt.mockResolvedValue({} as any); + + const result2 = await cipherStep.runDiagnostics(workingData, logger); + + expect(result2).toBe(true); + expect(cipherStep.canRecover(workingData)).toBe(false); + expect(cipherStep["undecryptableCipherIds"]).toHaveLength(0); + expect(cipherStep["decryptableCipherIds"]).toHaveLength(2); + }); }); describe("canRecover", () => { diff --git a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts index 01c2d9bc2a1..47000f8880b 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts @@ -25,6 +25,7 @@ export class CipherStep implements RecoveryStep { } this.undecryptableCipherIds = []; + this.decryptableCipherIds = []; // The tool is currently only implemented to handle ciphers that are corrupt for a user. For an organization, the case of // local user not having access to the organization key is not properly handled here, and should be implemented separately. // For now, this just filters out and does not consider corrupt organization ciphers. diff --git a/apps/web/src/app/key-management/data-recovery/steps/folder-step.spec.ts b/apps/web/src/app/key-management/data-recovery/steps/folder-step.spec.ts new file mode 100644 index 00000000000..8e59007732e --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/folder-step.spec.ts @@ -0,0 +1,404 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { UserKey } from "@bitwarden/common/types/key"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { Folder } from "@bitwarden/common/vault/models/domain/folder"; +import { DialogService } from "@bitwarden/components"; +import { PureCrypto } from "@bitwarden/sdk-internal"; +import { UserId } from "@bitwarden/user-core"; + +import { LogRecorder } from "../log-recorder"; + +import { FolderStep } from "./folder-step"; +import { RecoveryWorkingData } from "./recovery-step"; + +// Mock SdkLoadService +jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk-load.service", () => ({ + SdkLoadService: { + Ready: Promise.resolve(), + }, +})); + +jest.mock("@bitwarden/sdk-internal", () => ({ + PureCrypto: { + symmetric_decrypt_string: jest.fn(), + }, +})); + +describe("FolderStep", () => { + let folderStep: FolderStep; + let folderService: MockProxy; + let dialogService: MockProxy; + let logger: MockProxy; + + const mockUserKey = { + toEncoded: jest.fn().mockReturnValue("encoded-user-key"), + } as unknown as UserKey; + + beforeEach(() => { + folderService = mock(); + dialogService = mock(); + logger = mock(); + + folderStep = new FolderStep(folderService, dialogService); + + jest.clearAllMocks(); + }); + + describe("runDiagnostics", () => { + it("returns false and logs error when userKey is missing", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: null, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [], + }; + + const result = await folderStep.runDiagnostics(workingData, logger); + + expect(result).toBe(false); + expect(logger.record).toHaveBeenCalledWith("Missing user key"); + }); + + it("returns true when all folders are decryptable", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockReturnValue("decrypted-name"); + + const result = await folderStep.runDiagnostics(workingData, logger); + + expect(result).toBe(true); + expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith( + "encrypted-name-1", + "encoded-user-key", + ); + expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith( + "encrypted-name-2", + "encoded-user-key", + ); + expect(logger.record).toHaveBeenCalledWith("Found 0 undecryptable folders"); + expect(logger.record).toHaveBeenCalledWith("Found 2 decryptable folders"); + }); + + it("returns false and records folders with no name", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: null as null }; + const folder3 = { id: "folder-3", name: { encryptedString: null as null } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2, folder3] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockReturnValue("decrypted-name"); + + const result = await folderStep.runDiagnostics(workingData, logger); + + expect(result).toBe(false); + expect(logger.record).toHaveBeenCalledWith("Folder ID folder-2 has no name"); + expect(logger.record).toHaveBeenCalledWith("Folder ID folder-3 has no name"); + expect(logger.record).toHaveBeenCalledWith("Found 2 undecryptable folders"); + expect(logger.record).toHaveBeenCalledWith("Found 1 decryptable folders"); + }); + + it("returns false and records undecryptable folders", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + const folder3 = { id: "folder-3", name: { encryptedString: "encrypted-name-3" } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2, folder3] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock) + .mockReturnValueOnce("decrypted-name") // folder1 succeeds + .mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }) // folder2 fails + .mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }); // folder3 fails + + const result = await folderStep.runDiagnostics(workingData, logger); + + expect(result).toBe(false); + expect(logger.record).toHaveBeenCalledWith( + "Folder name for folder ID folder-2 was undecryptable", + ); + expect(logger.record).toHaveBeenCalledWith( + "Folder name for folder ID folder-3 was undecryptable", + ); + expect(logger.record).toHaveBeenCalledWith("Found 2 undecryptable folders"); + expect(logger.record).toHaveBeenCalledWith("Found 1 decryptable folders"); + }); + + it("returns correct results when running diagnostics multiple times", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2] as Folder[], + }; + + // First run: folder1 succeeds, folder2 fails + (PureCrypto.symmetric_decrypt_string as jest.Mock) + .mockReturnValueOnce("decrypted-name") + .mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }); + + const result1 = await folderStep.runDiagnostics(workingData, logger); + + expect(result1).toBe(false); + expect(folderStep.canRecover(workingData)).toBe(true); + + // Second run: all folders succeed + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockReturnValue("decrypted-name"); + + const result2 = await folderStep.runDiagnostics(workingData, logger); + + expect(result2).toBe(true); + expect(folderStep.canRecover(workingData)).toBe(false); + expect(folderStep["undecryptableFolderIds"]).toEqual([]); + expect(folderStep["decryptableFolderIds"]).toEqual(["folder-1", "folder-2"]); + }); + }); + + describe("canRecover", () => { + it("returns false when there are no undecryptable folders", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [ + { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }, + { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }, + ] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockReturnValue("decrypted-name"); + + await folderStep.runDiagnostics(workingData, logger); + const result = folderStep.canRecover(workingData); + + expect(result).toBe(false); + }); + + it("returns true when there are undecryptable folders but at least one decryptable folder", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [ + { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }, + { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }, + ] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock) + .mockReturnValueOnce("decrypted-name") + .mockImplementationOnce(() => { + throw new Error("Decryption failed"); + }); + + await folderStep.runDiagnostics(workingData, logger); + const result = folderStep.canRecover(workingData); + + expect(result).toBe(true); + }); + + it("returns false when all folders are undecryptable", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [ + { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }, + { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }, + ] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + + await folderStep.runDiagnostics(workingData, logger); + const result = folderStep.canRecover(workingData); + + expect(result).toBe(false); + }); + }); + + describe("runRecovery", () => { + it("logs and returns early when there are no undecryptable folders", async () => { + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [], + }; + + await folderStep.runRecovery(workingData, logger); + + expect(logger.record).toHaveBeenCalledWith("No undecryptable folders to recover"); + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(folderService.delete).not.toHaveBeenCalled(); + }); + + it("throws error when userId is missing", async () => { + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + + const workingData: RecoveryWorkingData = { + userId: "user-id" as UserId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + await folderStep.runDiagnostics(workingData, logger); + + // Now set userId to null for recovery + workingData.userId = null; + + await expect(folderStep.runRecovery(workingData, logger)).rejects.toThrow("Missing user ID"); + expect(logger.record).toHaveBeenCalledWith("Missing user ID"); + }); + + it("throws error when user cancels deletion", async () => { + const userId = "user-id" as UserId; + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + + const workingData: RecoveryWorkingData = { + userId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + await folderStep.runDiagnostics(workingData, logger); + + dialogService.openSimpleDialog.mockResolvedValue(false); + + await expect(folderStep.runRecovery(workingData, logger)).rejects.toThrow( + "Folder recovery cancelled by user", + ); + + expect(logger.record).toHaveBeenCalledWith("Showing confirmation dialog for 1 folders"); + expect(logger.record).toHaveBeenCalledWith("User cancelled folder deletion"); + expect(folderService.delete).not.toHaveBeenCalled(); + }); + + it("deletes undecryptable folders when user confirms", async () => { + const userId = "user-id" as UserId; + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + + const workingData: RecoveryWorkingData = { + userId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + await folderStep.runDiagnostics(workingData, logger); + + dialogService.openSimpleDialog.mockResolvedValue(true); + folderService.delete.mockResolvedValue(undefined); + + await folderStep.runRecovery(workingData, logger); + + expect(logger.record).toHaveBeenCalledWith("Showing confirmation dialog for 2 folders"); + expect(logger.record).toHaveBeenCalledWith("Deleting 2 folders"); + expect(folderService.delete).toHaveBeenCalledWith("folder-1", userId); + expect(folderService.delete).toHaveBeenCalledWith("folder-2", userId); + expect(logger.record).toHaveBeenCalledWith("Deleted folder folder-1"); + expect(logger.record).toHaveBeenCalledWith("Deleted folder folder-2"); + expect(logger.record).toHaveBeenCalledWith("Successfully deleted 2 folders"); + }); + + it("continues deleting folders even if some deletions fail", async () => { + const userId = "user-id" as UserId; + const folder1 = { id: "folder-1", name: { encryptedString: "encrypted-name-1" } }; + const folder2 = { id: "folder-2", name: { encryptedString: "encrypted-name-2" } }; + const folder3 = { id: "folder-3", name: { encryptedString: "encrypted-name-3" } }; + + const workingData: RecoveryWorkingData = { + userId, + userKey: mockUserKey, + encryptedPrivateKey: null, + isPrivateKeyCorrupt: false, + ciphers: [], + folders: [folder1, folder2, folder3] as Folder[], + }; + + (PureCrypto.symmetric_decrypt_string as jest.Mock).mockImplementation(() => { + throw new Error("Decryption failed"); + }); + await folderStep.runDiagnostics(workingData, logger); + + dialogService.openSimpleDialog.mockResolvedValue(true); + folderService.delete + .mockResolvedValueOnce(undefined) // folder1 succeeds + .mockRejectedValueOnce(new Error("Network error")) // folder2 fails + .mockResolvedValueOnce(undefined); // folder3 succeeds + + await folderStep.runRecovery(workingData, logger); + + expect(folderService.delete).toHaveBeenCalledTimes(3); + expect(logger.record).toHaveBeenCalledWith("Deleted folder folder-1"); + expect(logger.record).toHaveBeenCalledWith( + "Failed to delete folder folder-2: Error: Network error", + ); + expect(logger.record).toHaveBeenCalledWith("Deleted folder folder-3"); + }); + }); +}); diff --git a/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts index 90e252ce6c3..2087c360ddc 100644 --- a/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts +++ b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts @@ -25,6 +25,8 @@ export class FolderStep implements RecoveryStep { } this.undecryptableFolderIds = []; + this.decryptableFolderIds = []; + for (const folder of workingData.folders) { if (!folder.name?.encryptedString) { logger.record(`Folder ID ${folder.id} has no name`); diff --git a/apps/web/src/app/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/new-send/new-send-dropdown.component.spec.ts b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts index 4f5dda1745e..e9ef85867e7 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.spec.ts @@ -8,9 +8,9 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SendAddEditDialogComponent } from "@bitwarden/send-ui"; @@ -72,6 +72,7 @@ describe("NewSendDropdownComponent", () => { const openSpy = jest.spyOn(SendAddEditDialogComponent, "open"); const openDrawerSpy = jest.spyOn(SendAddEditDialogComponent, "openDrawer"); mockConfigService.getFeatureFlag.mockResolvedValue(false); + openSpy.mockReturnValue({ closed: of({}) } as any); await component.createSend(SendType.Text); @@ -85,6 +86,8 @@ describe("NewSendDropdownComponent", () => { mockConfigService.getFeatureFlag.mockImplementation(async (key) => key === FeatureFlag.SendUIRefresh ? true : false, ); + const mockRef = { closed: of({}) }; + openDrawerSpy.mockReturnValue(mockRef as any); await component.createSend(SendType.Text); diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts index 22f07e4fe92..68c8c188d31 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from "@angular/common"; import { Component, Input } from "@angular/core"; -import { firstValueFrom, Observable, of, switchMap } from "rxjs"; +import { firstValueFrom, Observable, of, switchMap, lastValueFrom } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -8,9 +8,15 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components"; -import { DefaultSendFormConfigService, SendAddEditDialogComponent } from "@bitwarden/send-ui"; +import { + DefaultSendFormConfigService, + SendAddEditDialogComponent, + SendItemDialogResult, +} from "@bitwarden/send-ui"; + +import { SendSuccessDrawerDialogComponent } from "../shared"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -60,12 +66,19 @@ export class NewSendDropdownComponent { if (!(await firstValueFrom(this.canAccessPremium$)) && type === SendType.File) { return; } - const formConfig = await this.addEditFormConfigService.buildConfig("add", undefined, type); - const useRefresh = await this.configService.getFeatureFlag(FeatureFlag.SendUIRefresh); + if (useRefresh) { - SendAddEditDialogComponent.openDrawer(this.dialogService, { formConfig }); + const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, { formConfig }); + if (dialogRef) { + const result = await lastValueFrom(dialogRef.closed); + if (result?.result === SendItemDialogResult.Saved && result?.send) { + this.dialogService.openDrawer(SendSuccessDrawerDialogComponent, { + data: result.send, + }); + } + } } else { SendAddEditDialogComponent.open(this.dialogService, { formConfig }); } diff --git a/apps/web/src/app/tools/send/send-access/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..060dc1958b1 --- /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 { SendAccess } from "@bitwarden/common/tools/send/models/domain/send-access"; +import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; +import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; +import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +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/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index 6418744a727..8a6f720bb45 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -17,100 +17,159 @@ {{ "sendDisabledWarning" | i18n }} -
-
-
-
- {{ "filters" | i18n }} -
-
-
- -
-
-
    -
  • - - - -
  • -
-
-
-
-

{{ "types" | i18n }}

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

{{ "types" | i18n }}

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

+ {{ "sendCreatedSuccessfully" | i18n }} +

+ +

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

+ + + {{ "sendLink" | i18n }} + + + +
+
+ + + + + +
diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts new file mode 100644 index 00000000000..67e01cd9ff0 --- /dev/null +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts @@ -0,0 +1,75 @@ +import { Component, ChangeDetectionStrategy, Inject, signal, computed } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { ActiveSendIcon } from "@bitwarden/assets/svg"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { DIALOG_DATA, DialogModule, ToastService, TypographyModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +@Component({ + imports: [SharedModule, DialogModule, TypographyModule], + templateUrl: "./send-success-drawer-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendSuccessDrawerDialogComponent { + readonly sendLink = signal(""); + activeSendIcon = ActiveSendIcon; + + // Computed property to get the dialog title based on send type + readonly dialogTitle = computed(() => { + return this.send.type === SendType.Text ? "newTextSend" : "newFileSend"; + }); + + constructor( + @Inject(DIALOG_DATA) public send: SendView, + private environmentService: EnvironmentService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, + ) { + void this.initLink(); + } + + async initLink() { + const env = await firstValueFrom(this.environmentService.environment$); + this.sendLink.set(env.getSendUrl() + this.send.accessId + "/" + this.send.urlB64Key); + } + + get formattedExpirationTime(): string { + if (!this.send.deletionDate) { + return ""; + } + const hoursAvailable = this.getHoursAvailable(this.send); + if (hoursAvailable < 24) { + return hoursAvailable === 1 + ? this.i18nService.t("oneHour").toLowerCase() + : this.i18nService.t("durationTimeHours", String(hoursAvailable)).toLowerCase(); + } + const daysAvailable = Math.ceil(hoursAvailable / 24); + return daysAvailable === 1 + ? this.i18nService.t("oneDay").toLowerCase() + : this.i18nService.t("days", String(daysAvailable)).toLowerCase(); + } + + private getHoursAvailable(send: SendView): number { + const now = new Date().getTime(); + const deletionDate = new Date(send.deletionDate).getTime(); + return Math.max(0, Math.ceil((deletionDate - now) / (1000 * 60 * 60))); + } + + copyLink() { + const link = this.sendLink(); + if (!link) { + return; + } + this.platformUtilsService.copyToClipboard(link); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("valueCopied", this.i18nService.t("sendLink")), + }); + } +} diff --git a/apps/web/src/app/vault/components/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..3dce773c7c1 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -2,8 +2,9 @@ {{ title }} - @if (cipherIsArchived) { - {{ "archiveNoun" | i18n }} + + @if (isCipherArchived) { + {{ "archived" | i18n }} }
@@ -83,8 +84,28 @@ } - @if (showDelete) { + @if (showActionButtons) {
+ @if (userCanArchive$ | async) { + @if (isCipherArchived) { + + } + @if (cipher?.canBeArchived) { + + } + } + + + + {{ "autoConfirmSetupHint" | i18n }} + + + +
+ + + `, + imports: [ButtonModule, DialogModule, CommonModule, JslibModule, BadgeComponent], +}) +export class AutoConfirmExtensionSetupDialogComponent { + constructor(public dialogRef: DialogRef) {} + + static open(dialogService: DialogService) { + return dialogService.open(AutoConfirmExtensionSetupDialogComponent, { + positionStrategy: new CenterPositionStrategy(), + }); + } +} 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..877a0fe918a --- /dev/null +++ b/libs/auto-confirm/src/components/auto-confirm-warning-dialog.component.ts @@ -0,0 +1,26 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +import { + ButtonModule, + CenterPositionStrategy, + 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, { + positionStrategy: new CenterPositionStrategy(), + }); + } +} diff --git a/libs/auto-confirm/src/components/index.ts b/libs/auto-confirm/src/components/index.ts new file mode 100644 index 00000000000..1cddd1d7e59 --- /dev/null +++ b/libs/auto-confirm/src/components/index.ts @@ -0,0 +1,2 @@ +export * from "./auto-confirm-extension-dialog.component"; +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 cb6cb227970..8a95169cda3 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 @@ -61,6 +61,7 @@ export class ProfileOrganizationResponse extends BaseResponse { userIsManagedByOrganization: boolean; useAccessIntelligence: boolean; useAdminSponsoredFamilies: boolean; + useDisableSMAdsForUsers: boolean; isAdminInitiated: boolean; ssoEnabled: boolean; ssoMemberDecryptionType?: MemberDecryptionType; @@ -135,6 +136,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/billing/models/response/bitwarden-subscription.response.ts b/libs/common/src/billing/models/response/bitwarden-subscription.response.ts new file mode 100644 index 00000000000..870c4de7e3a --- /dev/null +++ b/libs/common/src/billing/models/response/bitwarden-subscription.response.ts @@ -0,0 +1,102 @@ +import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response"; +import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Cart } from "@bitwarden/pricing"; +import { + BitwardenSubscription, + Storage, + SubscriptionStatus, + SubscriptionStatuses, +} from "@bitwarden/subscription"; + +export class BitwardenSubscriptionResponse extends BaseResponse { + status: SubscriptionStatus; + cart: Cart; + storage: Storage; + cancelAt?: Date; + canceled?: Date; + nextCharge?: Date; + suspension?: Date; + gracePeriod?: number; + + constructor(response: any) { + super(response); + + const status = this.getResponseProperty("Status"); + if ( + status !== SubscriptionStatuses.Incomplete && + status !== SubscriptionStatuses.IncompleteExpired && + status !== SubscriptionStatuses.Trialing && + status !== SubscriptionStatuses.Active && + status !== SubscriptionStatuses.PastDue && + status !== SubscriptionStatuses.Canceled && + status !== SubscriptionStatuses.Unpaid + ) { + throw new Error(`Failed to parse invalid subscription status: ${status}`); + } + this.status = status; + + this.cart = new CartResponse(this.getResponseProperty("Cart")); + this.storage = new StorageResponse(this.getResponseProperty("Storage")); + + const suspension = this.getResponseProperty("Suspension"); + if (suspension) { + this.suspension = new Date(suspension); + } + + const gracePeriod = this.getResponseProperty("GracePeriod"); + if (gracePeriod) { + this.gracePeriod = gracePeriod; + } + + const nextCharge = this.getResponseProperty("NextCharge"); + if (nextCharge) { + this.nextCharge = new Date(nextCharge); + } + + const cancelAt = this.getResponseProperty("CancelAt"); + if (cancelAt) { + this.cancelAt = new Date(cancelAt); + } + + const canceled = this.getResponseProperty("Canceled"); + if (canceled) { + this.canceled = new Date(canceled); + } + } + + toDomain = (): BitwardenSubscription => { + switch (this.status) { + case SubscriptionStatuses.Incomplete: + case SubscriptionStatuses.IncompleteExpired: + case SubscriptionStatuses.PastDue: + case SubscriptionStatuses.Unpaid: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + suspension: this.suspension!, + gracePeriod: this.gracePeriod!, + }; + } + case SubscriptionStatuses.Trialing: + case SubscriptionStatuses.Active: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + nextCharge: this.nextCharge!, + cancelAt: this.cancelAt, + }; + } + case SubscriptionStatuses.Canceled: { + return { + cart: this.cart, + storage: this.storage, + status: this.status, + canceled: this.canceled!, + }; + } + } + }; +} diff --git a/libs/common/src/billing/models/response/cart.response.ts b/libs/common/src/billing/models/response/cart.response.ts new file mode 100644 index 00000000000..c1a1d17521a --- /dev/null +++ b/libs/common/src/billing/models/response/cart.response.ts @@ -0,0 +1,97 @@ +import { + SubscriptionCadence, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Cart, CartItem, Discount } from "@bitwarden/pricing"; + +import { DiscountResponse } from "./discount.response"; + +export class CartItemResponse extends BaseResponse implements CartItem { + translationKey: string; + quantity: number; + cost: number; + discount?: Discount; + + constructor(response: any) { + super(response); + + this.translationKey = this.getResponseProperty("TranslationKey"); + this.quantity = this.getResponseProperty("Quantity"); + this.cost = this.getResponseProperty("Cost"); + const discount = this.getResponseProperty("Discount"); + if (discount) { + this.discount = discount; + } + } +} + +class PasswordManagerCartItemResponse extends BaseResponse { + seats: CartItem; + additionalStorage?: CartItem; + + constructor(response: any) { + super(response); + + this.seats = new CartItemResponse(this.getResponseProperty("Seats")); + const additionalStorage = this.getResponseProperty("AdditionalStorage"); + if (additionalStorage) { + this.additionalStorage = new CartItemResponse(additionalStorage); + } + } +} + +class SecretsManagerCartItemResponse extends BaseResponse { + seats: CartItem; + additionalServiceAccounts?: CartItem; + + constructor(response: any) { + super(response); + + this.seats = new CartItemResponse(this.getResponseProperty("Seats")); + const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts"); + if (additionalServiceAccounts) { + this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts); + } + } +} + +export class CartResponse extends BaseResponse implements Cart { + passwordManager: { + seats: CartItem; + additionalStorage?: CartItem; + }; + secretsManager?: { + seats: CartItem; + additionalServiceAccounts?: CartItem; + }; + cadence: SubscriptionCadence; + discount?: Discount; + estimatedTax: number; + + constructor(response: any) { + super(response); + + this.passwordManager = new PasswordManagerCartItemResponse( + this.getResponseProperty("PasswordManager"), + ); + + const secretsManager = this.getResponseProperty("SecretsManager"); + if (secretsManager) { + this.secretsManager = new SecretsManagerCartItemResponse(secretsManager); + } + + const cadence = this.getResponseProperty("Cadence"); + if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) { + throw new Error(`Failed to parse invalid cadence: ${cadence}`); + } + this.cadence = cadence; + + const discount = this.getResponseProperty("Discount"); + if (discount) { + this.discount = new DiscountResponse(discount); + } + + this.estimatedTax = this.getResponseProperty("EstimatedTax"); + } +} diff --git a/libs/common/src/billing/models/response/discount.response.ts b/libs/common/src/billing/models/response/discount.response.ts new file mode 100644 index 00000000000..03460a10df8 --- /dev/null +++ b/libs/common/src/billing/models/response/discount.response.ts @@ -0,0 +1,18 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing"; + +export class DiscountResponse extends BaseResponse implements Discount { + type: DiscountType; + value: number; + + constructor(response: any) { + super(response); + + const type = this.getResponseProperty("Type"); + if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) { + throw new Error(`Failed to parse invalid discount type: ${type}`); + } + this.type = type; + this.value = this.getResponseProperty("Value"); + } +} diff --git a/libs/common/src/billing/models/response/storage.response.ts b/libs/common/src/billing/models/response/storage.response.ts new file mode 100644 index 00000000000..7e270ccc934 --- /dev/null +++ b/libs/common/src/billing/models/response/storage.response.ts @@ -0,0 +1,16 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { Storage } from "@bitwarden/subscription"; + +export class StorageResponse extends BaseResponse implements Storage { + available: number; + used: number; + readableUsed: string; + + constructor(response: any) { + super(response); + + this.available = this.getResponseProperty("Available"); + this.used = this.getResponseProperty("Used"); + this.readableUsed = this.getResponseProperty("ReadableUsed"); + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index e5c29636585..ab8fe5decd8 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,11 +27,12 @@ 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", PM23341_Milestone_2 = "pm-23341-milestone-2", + PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page", + PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -40,7 +40,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", @@ -57,7 +56,6 @@ export enum FeatureFlag { /* DIRT */ EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike", PhishingDetection = "phishing-detection", - PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab", /* Vault */ PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", @@ -80,6 +78,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 +98,6 @@ const FALSE = false as boolean; */ export const DefaultFeatureFlagValue = { /* Admin Console Team */ - [FeatureFlag.CreateDefaultLocation]: FALSE, [FeatureFlag.AutoConfirm]: FALSE, [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, [FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE, @@ -117,7 +117,6 @@ export const DefaultFeatureFlagValue = { /* DIRT */ [FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE, [FeatureFlag.PhishingDetection]: FALSE, - [FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE, /* Vault */ [FeatureFlag.CipherKeyEncryption]: FALSE, @@ -136,11 +135,12 @@ 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, [FeatureFlag.PM23341_Milestone_2]: FALSE, + [FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE, + [FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, @@ -148,7 +148,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 +165,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/key-management/crypto/key-generation/default-key-generation.service.ts b/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts index 5f5da741707..ef1057c51e6 100644 --- a/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts +++ b/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts @@ -30,7 +30,7 @@ export class DefaultKeyGenerationService implements KeyGenerationService { ): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> { if (salt == null) { const bytes = await this.cryptoFunctionService.randomBytes(32); - salt = Utils.fromBufferToUtf8(bytes); + salt = Utils.fromBufferToUtf8(bytes.buffer as ArrayBuffer); } const material = await this.cryptoFunctionService.aesGenerateKey(bitLength); const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256"); 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..8839ea8ca50 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"; @@ -330,6 +330,7 @@ export class ApiService implements ApiServiceAbstraction { return new PaymentResponse(r); } + // TODO: Remove with deletion of pm-29594-update-individual-subscription-page postReinstatePremium(): Promise { return this.send("POST", "/accounts/reinstate-premium", null, true, false); } @@ -1252,8 +1253,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 +1284,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 +1302,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 +1369,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 +1524,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 +1579,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 +1673,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 +1693,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 +1725,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 +1752,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/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index 2c6377de0c9..bfa72b04087 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendResponse } from "../response/send.response"; import { SendFileData } from "./send-file.data"; diff --git a/libs/common/src/tools/send/models/domain/send-access.spec.ts b/libs/common/src/tools/send/models/domain/send-access.spec.ts index 686236bff8e..58083d8a4bb 100644 --- a/libs/common/src/tools/send/models/domain/send-access.spec.ts +++ b/libs/common/src/tools/send/models/domain/send-access.spec.ts @@ -1,7 +1,7 @@ import { mock } from "jest-mock-extended"; import { mockContainerService, mockEnc } from "../../../../../spec"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendAccessResponse } from "../response/send-access.response"; import { SendAccess } from "./send-access"; diff --git a/libs/common/src/tools/send/models/domain/send-access.ts b/libs/common/src/tools/send/models/domain/send-access.ts index 68d1af7b57e..1877a5c1148 100644 --- a/libs/common/src/tools/send/models/domain/send-access.ts +++ b/libs/common/src/tools/send/models/domain/send-access.ts @@ -3,7 +3,7 @@ import { EncString } from "../../../../key-management/crypto/models/enc-string"; import Domain from "../../../../platform/models/domain/domain-base"; import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendAccessResponse } from "../response/send-access.response"; import { SendAccessView } from "../view/send-access.view"; diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index dc9ca7d3444..b0cfd200483 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -11,7 +11,7 @@ import { EncryptService } from "../../../../key-management/crypto/abstractions/e import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../../../platform/services/container.service"; import { UserKey } from "../../../../types/key"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; import { Send } from "./send"; diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index 2bf16de8a44..b85509183b0 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -8,7 +8,7 @@ import { UserId } from "@bitwarden/common/types/guid"; import { EncString } from "../../../../key-management/crypto/models/enc-string"; import { Utils } from "../../../../platform/misc/utils"; import Domain from "../../../../platform/models/domain/domain-base"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; import { SendView } from "../view/send.view"; diff --git a/libs/common/src/tools/send/models/request/send.request.ts b/libs/common/src/tools/send/models/request/send.request.ts index f7e3ff26d7f..902ca0a2c54 100644 --- a/libs/common/src/tools/send/models/request/send.request.ts +++ b/libs/common/src/tools/send/models/request/send.request.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; import { Send } from "../domain/send"; diff --git a/libs/common/src/tools/send/models/response/send-access.response.ts b/libs/common/src/tools/send/models/response/send-access.response.ts index 65a98e527a4..54107017fcf 100644 --- a/libs/common/src/tools/send/models/response/send-access.response.ts +++ b/libs/common/src/tools/send/models/response/send-access.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { BaseResponse } from "../../../../models/response/base.response"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index 5c6bd4dc1a6..6bbaf91ebe8 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { BaseResponse } from "../../../../models/response/base.response"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; diff --git a/libs/common/src/tools/send/models/view/send-access.view.ts b/libs/common/src/tools/send/models/view/send-access.view.ts index cb8b29796af..9d1b56d88ec 100644 --- a/libs/common/src/tools/send/models/view/send-access.view.ts +++ b/libs/common/src/tools/send/models/view/send-access.view.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { View } from "../../../../models/view/view"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { SendAccess } from "../domain/send-access"; import { SendFileView } from "./send-file.view"; diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index 54657b12438..1bb3b527a73 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -4,7 +4,7 @@ import { View } from "../../../../models/view/view"; import { Utils } from "../../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { DeepJsonify } from "../../../../types/deep-jsonify"; -import { SendType } from "../../enums/send-type"; +import { SendType } from "../../types/send-type"; import { Send } from "../domain/send"; import { SendFileView } from "./send-file.view"; diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts index f709553646f..1c931b7ad98 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -6,7 +6,6 @@ import { FileUploadService, } from "../../../platform/abstractions/file-upload/file-upload.service"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; -import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; import { SendAccessRequest } from "../models/request/send-access.request"; @@ -16,6 +15,7 @@ import { SendFileDownloadDataResponse } from "../models/response/send-file-downl import { SendFileUploadDataResponse } from "../models/response/send-file-upload-data.response"; import { SendResponse } from "../models/response/send.response"; import { SendAccessView } from "../models/view/send-access.view"; +import { SendType } from "../types/send-type"; import { SendApiService as SendApiServiceAbstraction } from "./send-api.service.abstraction"; import { InternalSendService } from "./send.service.abstraction"; diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 397ae905e31..fb99ddbe3bc 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -24,13 +24,13 @@ import { ContainerService } from "../../../platform/services/container.service"; import { SelfHostedEnvironment } from "../../../platform/services/default-environment.service"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; -import { SendType } from "../enums/send-type"; import { SendFileApi } from "../models/api/send-file.api"; import { SendTextApi } from "../models/api/send-text.api"; import { SendFileData } from "../models/data/send-file.data"; import { SendTextData } from "../models/data/send-text.data"; import { SendData } from "../models/data/send.data"; import { SendView } from "../models/view/send.view"; +import { SendType } from "../types/send-type"; import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions"; import { SendStateProvider } from "./send-state.provider"; diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 810dbc05a2f..c274d90146e 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -16,7 +16,6 @@ import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; -import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; import { SendFile } from "../models/domain/send-file"; @@ -24,6 +23,7 @@ import { SendText } from "../models/domain/send-text"; import { SendWithIdRequest } from "../models/request/send-with-id.request"; import { SendView } from "../models/view/send.view"; import { SEND_KDF_ITERATIONS } from "../send-kdf"; +import { SendType } from "../types/send-type"; import { SendStateProvider } from "./send-state.provider.abstraction"; import { InternalSendService as InternalSendServiceAbstraction } from "./send.service.abstraction"; diff --git a/libs/common/src/tools/send/services/test-data/send-tests.data.ts b/libs/common/src/tools/send/services/test-data/send-tests.data.ts index 784d54bd71f..c1d04ab2926 100644 --- a/libs/common/src/tools/send/services/test-data/send-tests.data.ts +++ b/libs/common/src/tools/send/services/test-data/send-tests.data.ts @@ -1,12 +1,12 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { EncString } from "../../../../key-management/crypto/models/enc-string"; -import { SendType } from "../../enums/send-type"; import { SendTextApi } from "../../models/api/send-text.api"; import { SendTextData } from "../../models/data/send-text.data"; import { SendData } from "../../models/data/send.data"; import { Send } from "../../models/domain/send"; import { SendView } from "../../models/view/send.view"; +import { SendType } from "../../types/send-type"; export function testSendViewData(id: string, name: string) { const data = new SendView({} as any); diff --git a/libs/common/src/tools/send/types/send-filter-type.ts b/libs/common/src/tools/send/types/send-filter-type.ts new file mode 100644 index 00000000000..dd26536076a --- /dev/null +++ b/libs/common/src/tools/send/types/send-filter-type.ts @@ -0,0 +1,7 @@ +export const SendFilterType = Object.freeze({ + All: "all", + Text: "text", + File: "file", +} as const); + +export type SendFilterType = (typeof SendFilterType)[keyof typeof SendFilterType]; diff --git a/libs/common/src/tools/send/enums/send-type.ts b/libs/common/src/tools/send/types/send-type.ts similarity index 100% rename from libs/common/src/tools/send/enums/send-type.ts rename to libs/common/src/tools/send/types/send-type.ts diff --git a/libs/common/src/vault/abstractions/cipher-archive.service.ts b/libs/common/src/vault/abstractions/cipher-archive.service.ts index 0969b7de1ac..3a5071dc51a 100644 --- a/libs/common/src/vault/abstractions/cipher-archive.service.ts +++ b/libs/common/src/vault/abstractions/cipher-archive.service.ts @@ -3,12 +3,14 @@ import { Observable } from "rxjs"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { CipherData } from "../models/data/cipher.data"; + export abstract class CipherArchiveService { abstract hasArchiveFlagEnabled$: Observable; abstract archivedCiphers$(userId: UserId): Observable; abstract userCanArchive$(userId: UserId): Observable; abstract userHasPremium$(userId: UserId): Observable; - abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; - abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; + abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; + abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise; abstract showSubscriptionEndedMessaging$(userId: UserId): Observable; } 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/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index db360f7f991..89f59665681 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -109,6 +109,10 @@ export class CipherView implements View, InitializerMetadata { return this.item?.subTitle; } + get canBeArchived(): boolean { + return !this.isDeleted && !this.isArchived; + } + get hasPasswordHistory(): boolean { return this.passwordHistory && this.passwordHistory.length > 0; } 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..60589ed58db 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 @@ -1,3 +1,7 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ import { mock } from "jest-mock-extended"; import { of, firstValueFrom, BehaviorSubject } from "rxjs"; @@ -165,6 +169,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..0f1120b1de8 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.ts @@ -18,6 +18,7 @@ import { } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { CipherArchiveService } from "../abstractions/cipher-archive.service"; +import { CipherData } from "../models/data/cipher.data"; export class DefaultCipherArchiveService implements CipherArchiveService { constructor( @@ -71,21 +72,30 @@ 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 }), ); } - async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { + async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { const request = new CipherBulkArchiveRequest(Array.isArray(ids) ? ids : [ids]); const r = await this.apiService.send("PUT", "/ciphers/archive", request, true, true); const response = new ListResponse(r, CipherResponse); const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId)); + // prevent mutating ciphers$ state + const localCiphers = structuredClone(currentCiphers); for (const cipher of response.data) { - const localCipher = currentCiphers[cipher.id as CipherId]; + const localCipher = localCiphers[cipher.id as CipherId]; if (localCipher == null) { continue; @@ -95,18 +105,21 @@ export class DefaultCipherArchiveService implements CipherArchiveService { localCipher.revisionDate = cipher.revisionDate; } - await this.cipherService.upsert(Object.values(currentCiphers), userId); + await this.cipherService.upsert(Object.values(localCiphers), userId); + return response.data[0]; } - async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { + async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise { const request = new CipherBulkUnarchiveRequest(Array.isArray(ids) ? ids : [ids]); const r = await this.apiService.send("PUT", "/ciphers/unarchive", request, true, true); const response = new ListResponse(r, CipherResponse); const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId)); + // prevent mutating ciphers$ state + const localCiphers = structuredClone(currentCiphers); for (const cipher of response.data) { - const localCipher = currentCiphers[cipher.id as CipherId]; + const localCipher = localCiphers[cipher.id as CipherId]; if (localCipher == null) { continue; @@ -116,6 +129,7 @@ export class DefaultCipherArchiveService implements CipherArchiveService { localCipher.revisionDate = cipher.revisionDate; } - await this.cipherService.upsert(Object.values(currentCiphers), userId); + await this.cipherService.upsert(Object.values(localCiphers), userId); + return response.data[0]; } } 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()" > + + `, + imports: [PopoverTriggerForDirective, PopoverComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class TestPopoverTriggerComponent { + isOpen = false; + readonly directive = viewChild("trigger", { read: PopoverTriggerForDirective }); + readonly popoverComponent = viewChild("popoverComponent", { read: PopoverComponent }); + readonly templateRef = viewChild("trigger", { read: TemplateRef }); +} + +describe("PopoverTriggerForDirective", () => { + let fixture: ComponentFixture; + let component: TestPopoverTriggerComponent; + let directive: PopoverTriggerForDirective; + let overlayRef: Partial; + let overlay: Partial; + let ngZone: NgZone; + + beforeEach(async () => { + // Create mock overlay ref + overlayRef = { + backdropElement: document.createElement("div"), + attach: jest.fn(), + detach: jest.fn(), + dispose: jest.fn(), + detachments: jest.fn().mockReturnValue(new Subject()), + keydownEvents: jest.fn().mockReturnValue(new Subject()), + backdropClick: jest.fn().mockReturnValue(new Subject()), + }; + + // Create mock overlay + const mockPositionStrategy = { + flexibleConnectedTo: jest.fn().mockReturnThis(), + withPositions: jest.fn().mockReturnThis(), + withLockedPosition: jest.fn().mockReturnThis(), + withFlexibleDimensions: jest.fn().mockReturnThis(), + withPush: jest.fn().mockReturnThis(), + }; + + overlay = { + create: jest.fn().mockReturnValue(overlayRef), + position: jest.fn().mockReturnValue(mockPositionStrategy), + scrollStrategies: { + reposition: jest.fn().mockReturnValue({}), + } as any, + }; + + await TestBed.configureTestingModule({ + imports: [TestPopoverTriggerComponent], + providers: [{ provide: Overlay, useValue: overlay }], + }).compileComponents(); + + fixture = TestBed.createComponent(TestPopoverTriggerComponent); + component = fixture.componentInstance; + ngZone = TestBed.inject(NgZone); + fixture.detectChanges(); + directive = component.directive()!; + }); + + afterEach(() => { + fixture.destroy(); + }); + + describe("Initial popover open with RAF delay", () => { + it("should use double RAF delay on first open", fakeAsync(() => { + // Spy on requestAnimationFrame to verify it's being called + const rafSpy = jest.spyOn(window, "requestAnimationFrame"); + + // Set popoverOpen signal directly on the directive inside NgZone + ngZone.run(() => { + directive.popoverOpen.set(true); + fixture.detectChanges(); + }); + + // After effect execution, RAF should be scheduled but not executed yet + expect(overlay.create).not.toHaveBeenCalled(); + + // Execute first RAF - tick(16) advances time by one animation frame (16ms) + // This executes the first requestAnimationFrame callback + tick(16); + expect(overlay.create).not.toHaveBeenCalled(); + + // Execute second RAF - the nested requestAnimationFrame callback + tick(16); + expect(overlay.create).toHaveBeenCalled(); + expect(overlayRef.attach).toHaveBeenCalled(); + + rafSpy.mockRestore(); + flush(); + })); + + it("should skip RAF delay on subsequent opens", fakeAsync(() => { + // First open with double RAF delay + ngZone.run(() => { + directive.popoverOpen.set(true); + fixture.detectChanges(); + }); + // Execute both RAF callbacks (16ms each = 32ms total for first open) + tick(16); // First RAF + tick(16); // Second RAF + expect(overlay.create).toHaveBeenCalledTimes(1); + jest.mocked(overlay.create).mockClear(); + + // Close by clicking + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + // Second open should skip RAF delay (hasInitialized is now true) + ngZone.run(() => { + directive.popoverOpen.set(true); + fixture.detectChanges(); + }); + // Only need tick(0) to flush microtasks - NO RAF delay on subsequent opens + tick(0); + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + }); + + describe("Race condition prevention", () => { + it("should prevent multiple RAF scheduling when toggled rapidly", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Try to toggle back to false before RAF completes + ngZone.run(() => { + directive.popoverOpen.set(false); + }); + fixture.detectChanges(); + + // Try to toggle back to true + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Execute RAFs + tick(16); + tick(16); + + // Should only create overlay once + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + + it("should not schedule new RAF if one is already pending", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Try to open again while RAF is pending (shouldn't schedule another) + ngZone.run(() => { + directive.popoverOpen.set(false); + }); + fixture.detectChanges(); + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + tick(16); + tick(16); + + // Should only have created one overlay + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + + it("should prevent duplicate overlays from click handler during RAF", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Click to close before RAF completes - this should cancel the RAF and prevent overlay creation + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + // Verify popoverOpen was set to false + expect(directive.popoverOpen()).toBe(false); + + tick(16); + tick(16); + + // Should NOT have created any overlay because RAF was canceled + expect(overlay.create).not.toHaveBeenCalled(); + + flush(); + })); + }); + + describe("Component destruction during RAF", () => { + it("should cancel RAF callbacks when component is destroyed", fakeAsync(() => { + const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame"); + + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Destroy component before RAF completes + fixture.destroy(); + + // Should have cancelled animation frames + expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + + cancelAnimationFrameSpy.mockRestore(); + + flush(); + })); + + it("should not create overlay if destroyed during RAF delay", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Execute first RAF + tick(16); + + // Destroy before second RAF + fixture.destroy(); + + // Execute second RAF (should be no-op) + tick(16); + + expect(overlay.create).not.toHaveBeenCalled(); + + flush(); + })); + + it("should set isDestroyed flag and prevent further operations", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + // Destroy the component + fixture.destroy(); + + // Try to toggle (should be blocked by isDestroyed check) + const button = fixture.nativeElement.querySelector("button"); + button.click(); + + expect(overlay.create).toHaveBeenCalledTimes(1); // Only from initial open + + flush(); + })); + }); + + describe("Click handling", () => { + it("should open popover on click when closed", fakeAsync(() => { + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + expect(component.isOpen).toBe(true); + expect(overlay.create).toHaveBeenCalled(); + + flush(); + })); + + it("should close popover on click when open", fakeAsync(() => { + // Open first + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + // Click to close + const button = fixture.nativeElement.querySelector("button"); + button.click(); + fixture.detectChanges(); + + expect(component.isOpen).toBe(false); + expect(overlayRef.dispose).toHaveBeenCalled(); + + flush(); + })); + + it("should not process clicks after component is destroyed", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + const initialCreateCount = jest.mocked(overlay.create).mock.calls.length; + + fixture.destroy(); + + const button = fixture.nativeElement.querySelector("button"); + button.click(); + + // Should not have created additional overlay + expect(overlay.create).toHaveBeenCalledTimes(initialCreateCount); + + flush(); + })); + }); + + describe("Resource cleanup", () => { + it("should cancel both RAF IDs in disposeAll", fakeAsync(() => { + const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame"); + + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + // Trigger disposal while RAF is pending + directive.ngOnDestroy(); + + // Should cancel animation frames + expect(cancelAnimationFrameSpy).toHaveBeenCalled(); + + cancelAnimationFrameSpy.mockRestore(); + + flush(); + })); + + it("should dispose overlay on destroy", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + expect(overlayRef.attach).toHaveBeenCalled(); + + fixture.destroy(); + + expect(overlayRef.dispose).toHaveBeenCalled(); + + flush(); + })); + + it("should unsubscribe from closed events on destroy", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + // Get the subscription (it's private, so we'll verify via disposal) + fixture.destroy(); + + // Should have disposed overlay which triggers cleanup + expect(overlayRef.dispose).toHaveBeenCalled(); + + flush(); + })); + }); + + describe("Overlay guard in openPopover", () => { + it("should not create duplicate overlay if overlayRef already exists", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + expect(overlay.create).toHaveBeenCalledTimes(1); + + // Try to open again + ngZone.run(() => { + directive.popoverOpen.set(false); + }); + fixture.detectChanges(); + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + + expect(overlay.create).toHaveBeenCalledTimes(1); + + flush(); + })); + }); + + describe("aria-expanded attribute", () => { + it("should set aria-expanded to false when closed", () => { + const button = fixture.nativeElement.querySelector("button"); + expect(button.getAttribute("aria-expanded")).toBe("false"); + }); + + it("should set aria-expanded to true when open", fakeAsync(() => { + ngZone.run(() => { + directive.popoverOpen.set(true); + }); + fixture.detectChanges(); + tick(16); + tick(16); + + const button = fixture.nativeElement.querySelector("button"); + expect(button.getAttribute("aria-expanded")).toBe("true"); + + flush(); + })); + }); +}); diff --git a/libs/components/src/popover/popover-trigger-for.directive.ts b/libs/components/src/popover/popover-trigger-for.directive.ts index cb114f1fbc3..176a736fb39 100644 --- a/libs/components/src/popover/popover-trigger-for.directive.ts +++ b/libs/components/src/popover/popover-trigger-for.directive.ts @@ -1,12 +1,12 @@ import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; import { TemplatePortal } from "@angular/cdk/portal"; import { - AfterViewInit, Directive, ElementRef, HostListener, OnDestroy, ViewContainerRef, + effect, input, model, } from "@angular/core"; @@ -22,7 +22,7 @@ import { PopoverComponent } from "./popover.component"; "[attr.aria-expanded]": "this.popoverOpen()", }, }) -export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { +export class PopoverTriggerForDirective implements OnDestroy { readonly popoverOpen = model(false); readonly popover = input.required({ alias: "bitPopoverTriggerFor" }); @@ -31,6 +31,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { private overlayRef: OverlayRef | null = null; private closedEventsSub: Subscription | null = null; + private hasInitialized = false; + private rafId1: number | null = null; + private rafId2: number | null = null; + private isDestroyed = false; get positions() { if (!this.position()) { @@ -65,10 +69,44 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { private elementRef: ElementRef, private viewContainerRef: ViewContainerRef, private overlay: Overlay, - ) {} + ) { + effect(() => { + if (this.isDestroyed || !this.popoverOpen() || this.overlayRef) { + return; + } + + if (this.hasInitialized) { + this.openPopover(); + return; + } + + if (this.rafId1 !== null || this.rafId2 !== null) { + return; + } + + // Initial open - wait for layout to stabilize + // First RAF: Waits for Angular's change detection to complete and queues the next paint + this.rafId1 = requestAnimationFrame(() => { + // Second RAF: Ensures the browser has actually painted that frame and all layout/position calculations are final + this.rafId2 = requestAnimationFrame(() => { + if (this.isDestroyed || !this.popoverOpen() || this.overlayRef) { + return; + } + this.openPopover(); + this.hasInitialized = true; + this.rafId2 = null; + }); + this.rafId1 = null; + }); + }); + } @HostListener("click") togglePopover() { + if (this.isDestroyed) { + return; + } + if (this.popoverOpen()) { this.closePopover(); } else { @@ -77,6 +115,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { } private openPopover() { + if (this.overlayRef) { + return; + } + this.popoverOpen.set(true); this.overlayRef = this.overlay.create(this.defaultPopoverConfig); @@ -104,7 +146,7 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { } private destroyPopover() { - if (!this.overlayRef || !this.popoverOpen()) { + if (!this.popoverOpen()) { return; } @@ -117,15 +159,19 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit { this.closedEventsSub = null; this.overlayRef?.dispose(); this.overlayRef = null; - } - ngAfterViewInit() { - if (this.popoverOpen()) { - this.openPopover(); + if (this.rafId1 !== null) { + cancelAnimationFrame(this.rafId1); + this.rafId1 = null; + } + if (this.rafId2 !== null) { + cancelAnimationFrame(this.rafId2); + this.rafId2 = null; } } ngOnDestroy() { + this.isDestroyed = true; this.disposeAll(); } diff --git a/libs/importer/src/importers/base-importer.ts b/libs/importer/src/importers/base-importer.ts index f8acb5e0643..7196a83783a 100644 --- a/libs/importer/src/importers/base-importer.ts +++ b/libs/importer/src/importers/base-importer.ts @@ -319,8 +319,6 @@ export abstract class BaseImporter { } if (this.isNullOrWhitespace(cipher.notes)) { cipher.notes = null; - } else { - cipher.notes = cipher.notes.trim(); } } diff --git a/libs/importer/src/importers/enpass/enpass-json-importer.ts b/libs/importer/src/importers/enpass/enpass-json-importer.ts index e863e9b1666..83645d8f304 100644 --- a/libs/importer/src/importers/enpass/enpass-json-importer.ts +++ b/libs/importer/src/importers/enpass/enpass-json-importer.ts @@ -64,7 +64,10 @@ export class EnpassJsonImporter extends BaseImporter implements Importer { } } - cipher.notes += "\n" + this.getValueOrDefault(item.note, ""); + const note = this.getValueOrDefault(item.note, ""); + if (note) { + cipher.notes = note.trimEnd(); + } this.convertToNoteIfNeeded(cipher); this.cleanupCipher(cipher); result.ciphers.push(cipher); diff --git a/libs/importer/src/importers/keeper/keeper-csv-importer.ts b/libs/importer/src/importers/keeper/keeper-csv-importer.ts index 6c265ac31f5..9f86b4a66f4 100644 --- a/libs/importer/src/importers/keeper/keeper-csv-importer.ts +++ b/libs/importer/src/importers/keeper/keeper-csv-importer.ts @@ -21,7 +21,7 @@ export class KeeperCsvImporter extends BaseImporter implements Importer { const notes = this.getValueOrDefault(value[5]); if (notes) { - cipher.notes = `${notes}\n`; + cipher.notes = notes.trimEnd(); } cipher.name = this.getValueOrDefault(value[1], "--"); diff --git a/libs/importer/src/importers/myki-csv-importer.ts b/libs/importer/src/importers/myki-csv-importer.ts index 1180e2fbee1..07bf6a11b63 100644 --- a/libs/importer/src/importers/myki-csv-importer.ts +++ b/libs/importer/src/importers/myki-csv-importer.ts @@ -50,7 +50,7 @@ export class MykiCsvImporter extends BaseImporter implements Importer { results.forEach((value) => { const cipher = this.initLoginCipher(); cipher.name = this.getValueOrDefault(value.nickname, "--"); - cipher.notes = this.getValueOrDefault(value.additionalInfo); + cipher.notes = this.getValueOrDefault(value.additionalInfo, "").trimEnd(); if (value.url !== undefined) { // Accounts @@ -132,7 +132,7 @@ export class MykiCsvImporter extends BaseImporter implements Importer { cipher.secureNote = new SecureNoteView(); cipher.type = CipherType.SecureNote; cipher.secureNote.type = SecureNoteType.Generic; - cipher.notes = this.getValueOrDefault(value.content); + cipher.notes = this.getValueOrDefault(value.content, "").trimEnd(); this.importUnmappedFields(cipher, value, _mappedUserNoteColumns); } else { diff --git a/libs/importer/src/importers/netwrix/netwrix-passwordsecure-csv-importer.ts b/libs/importer/src/importers/netwrix/netwrix-passwordsecure-csv-importer.ts index 6cc31e78461..acdba76e986 100644 --- a/libs/importer/src/importers/netwrix/netwrix-passwordsecure-csv-importer.ts +++ b/libs/importer/src/importers/netwrix/netwrix-passwordsecure-csv-importer.ts @@ -35,7 +35,7 @@ export class NetwrixPasswordSecureCsvImporter extends BaseImporter implements Im const notes = this.getValueOrDefault(row.Informationen); if (notes) { - cipher.notes = `${notes}\n`; + cipher.notes = notes.trimEnd(); } cipher.name = this.getValueOrDefault(row.Beschreibung, "--"); diff --git a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts index d19b5e7d0f3..4571a6957c4 100644 --- a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts +++ b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts @@ -97,7 +97,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { this.processSections(category, item.details.sections, cipher); if (!this.isNullOrWhitespace(item.details.notesPlain)) { - cipher.notes = item.details.notesPlain.split(this.newLineRegex).join("\n") + "\n"; + cipher.notes = item.details.notesPlain.split(this.newLineRegex).join("\n").trimEnd(); } this.convertToNoteIfNeeded(cipher); diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 400beae5179..829bd04e994 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -374,10 +374,13 @@ export class ImportService implements ImportServiceAbstraction { private async handleIndividualImport(importResult: ImportResult, userId: UserId) { const request = new ImportCiphersRequest(); - for (let i = 0; i < importResult.ciphers.length; i++) { - const c = await this.cipherService.encrypt(importResult.ciphers[i], userId); - request.ciphers.push(new CipherRequest(c)); + + const encryptedCiphers = await this.cipherService.encryptMany(importResult.ciphers, userId); + + for (const encryptedCipher of encryptedCiphers) { + request.ciphers.push(new CipherRequest(encryptedCipher)); } + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); if (importResult.folders != null) { @@ -400,11 +403,18 @@ export class ImportService implements ImportServiceAbstraction { userId: UserId, ) { const request = new ImportOrganizationCiphersRequest(); - for (let i = 0; i < importResult.ciphers.length; i++) { - importResult.ciphers[i].organizationId = organizationId; - const c = await this.cipherService.encrypt(importResult.ciphers[i], userId); - request.ciphers.push(new CipherRequest(c)); + + // Set organization ID on all ciphers before batch encryption + importResult.ciphers.forEach((cipher) => { + cipher.organizationId = organizationId; + }); + + const encryptedCiphers = await this.cipherService.encryptMany(importResult.ciphers, userId); + + for (const encryptedCipher of encryptedCiphers) { + request.ciphers.push(new CipherRequest(encryptedCipher)); } + if (importResult.collections != null) { for (let i = 0; i < importResult.collections.length; i++) { importResult.collections[i].organizationId = organizationId; diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index 71201361a0c..c1577b76a4d 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -121,11 +121,7 @@ - @if ( - (unlockWithMasterPasswordUnlockDataFlag$ | async) && - unlockOptions.masterPassword.enabled && - activeUnlockOption === UnlockOption.MasterPassword - ) { + @if (unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword) { - } @else { - -
- - {{ "masterPass" | i18n }} - - - - - - -
- - -

{{ "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 2a5513f0698..508f45995db 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -129,18 +129,13 @@ 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. + * @deprecated Interacting with the master key directly is prohibited. + * For new features please use the KM provided SDK methods for user cryptography initialization or reach out to the KM team. + * @throws Error when master key is null or undefined. + * @param masterKey The user's master key. * @returns A new user key and the master key protected version of it */ - abstract makeUserKey(masterKey: MasterKey | null): Promise<[UserKey, EncString]>; - /** - * Generates a new user key for a V1 user - * Note: This will be replaced by a higher level function to initialize a whole users cryptographic state in the near future. - * @returns A new user key - */ - abstract makeUserKeyV1(): Promise; + abstract makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]>; /** * Clears the user's stored version of the user key * @param userId The desired user @@ -337,9 +332,9 @@ export abstract class KeyService { abstract getFingerprint(fingerprintMaterial: string, publicKey: Uint8Array): Promise; /** * Generates a new keypair - * @param key A key to encrypt the private key with. If not provided, - * defaults to the user key - * @returns A new keypair: [publicKey in Base64, encrypted privateKey] + * @deprecated New use-cases of this function are prohibited. Low-level cryptographic constructions and initialization should be done in the SDK. + * @param key A symmetric key to wrap the newly created private key with. + * @returns A new keypair: [publicKey in Base64, wrapped privateKey] * @throws If the provided key is a null-ish value. */ abstract makeKeyPair(key: SymmetricCryptoKey): Promise<[string, EncString]>; @@ -364,6 +359,8 @@ export abstract class KeyService { /** * Initialize all necessary crypto keys needed for a new account. * Warning! This completely replaces any existing keys! + * @deprecated New use cases for cryptography initialization should be done in the SDK. + * Current usage is actively being migrated see PM-21771 for details. * @param userId The user id of the target user. * @returns The user's newly created public key, private key, and encrypted private key * @throws An error if the userId is null or undefined. diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 2215d2b7227..136c24ac6e1 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -178,6 +178,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 bb662bcbc33..95a76fd32ae 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -205,28 +205,15 @@ 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); return this.buildProtectedSymmetricKey(masterKey, newUserKey); } - async makeUserKeyV1(): Promise { - const newUserKey = await this.keyGenerationService.createKey(512); - return newUserKey as UserKey; - } - /** * Clears the user key. Clears all stored versions of the user keys as well, such as the biometrics key * @param userId The desired user diff --git a/libs/nx-plugin/src/generators/basic-lib.spec.ts b/libs/nx-plugin/src/generators/basic-lib.spec.ts index 9fd7a702375..2018593046b 100644 --- a/libs/nx-plugin/src/generators/basic-lib.spec.ts +++ b/libs/nx-plugin/src/generators/basic-lib.spec.ts @@ -24,7 +24,7 @@ describe("basic-lib generator", () => { expect(tsconfigContent).not.toBeNull(); const tsconfig = JSON.parse(tsconfigContent?.toString() ?? ""); expect(tsconfig.compilerOptions.paths[`@bitwarden/${options.name}`]).toEqual([ - `libs/test/src/index.ts`, + `./libs/test/src/index.ts`, ]); }); diff --git a/libs/nx-plugin/src/generators/basic-lib.ts b/libs/nx-plugin/src/generators/basic-lib.ts index 4f2f542ac89..c0d8a528841 100644 --- a/libs/nx-plugin/src/generators/basic-lib.ts +++ b/libs/nx-plugin/src/generators/basic-lib.ts @@ -82,7 +82,7 @@ function updateTsConfigPath(tree: Tree, name: string, srcRoot: string) { updateJson(tree, "tsconfig.base.json", (json) => { const paths = json.compilerOptions.paths || {}; - paths[`@bitwarden/${name}`] = [`${srcRoot}/index.ts`]; + paths[`@bitwarden/${name}`] = [`./${srcRoot}/index.ts`]; json.compilerOptions.paths = paths; return json; diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index 85695ea1395..e916de3995d 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..3162e740cb0 --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.mdx @@ -0,0 +1,196 @@ +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) + - [Download License Disabled](#download-license-disabled) + - [Cancel Subscription Disabled](#cancel-subscription-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 | +| ---------------------------- | --------- | ----------------------------------------------------------------------------- | +| `downloadLicenseDisabled` | `boolean` | Optional. Disables download license button when true. Defaults to `false`. | +| `cancelSubscriptionDisabled` | `boolean` | Optional. Disables cancel subscription button when true. Defaults to `false`. | + +### Outputs + +| 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 `downloadLicenseDisabled` and `cancelSubscriptionDisabled` independently to control +button states during async operations like downloading the license or processing subscription +cancellation. + +### Download License Disabled + +Component with only the download license button disabled: + + + +```html + + +``` + +### Cancel Subscription Disabled + +Component with only the cancel subscription button disabled: + + + +```html + + +``` + +## Features + +- **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) +- Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` to control button states during + operations +- Provide clear user feedback after action completion +- Consider adding additional safety measures for subscription cancellation +- Control button states independently based on business logic + +### ❌ Don't + +- 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..3346c287beb --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.spec.ts @@ -0,0 +1,144 @@ +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("button disabled states", () => { + it("should enable both buttons by default", () => { + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + + it("should disable download license button when downloadLicenseDisabled is true", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + }); + + it("should disable cancel subscription button when cancelSubscriptionDisabled is true", () => { + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should disable both buttons independently", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should allow download enabled while cancel disabled", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", false); + fixture.componentRef.setInput("cancelSubscriptionDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should allow cancel enabled while download disabled", () => { + fixture.componentRef.setInput("downloadLicenseDisabled", true); + fixture.componentRef.setInput("cancelSubscriptionDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + 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..7dd7a5375fe --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.stories.ts @@ -0,0 +1,66 @@ +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: { + downloadLicenseDisabled: true, + cancelSubscriptionDisabled: true, + }, +}; + +export const DownloadLicenseDisabled: Story = { + name: "Download License Disabled", + args: { + downloadLicenseDisabled: true, + cancelSubscriptionDisabled: false, + }, +}; + +export const CancelSubscriptionDisabled: Story = { + name: "Cancel Subscription Disabled", + args: { + downloadLicenseDisabled: false, + cancelSubscriptionDisabled: true, + }, +}; diff --git a/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts new file mode 100644 index 00000000000..6c633a43d93 --- /dev/null +++ b/libs/subscription/src/components/additional-options-card/additional-options-card.component.ts @@ -0,0 +1,27 @@ +import { Component, ChangeDetectionStrategy, output, input } from "@angular/core"; + +import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +export const AdditionalOptionsCardActions = { + DownloadLicense: "download-license", + CancelSubscription: "cancel-subscription", +} as const; + +export type AdditionalOptionsCardAction = + (typeof AdditionalOptionsCardActions)[keyof typeof AdditionalOptionsCardActions]; + +@Component({ + selector: "billing-additional-options-card", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./additional-options-card.component.html", + imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe], +}) +export class AdditionalOptionsCardComponent { + readonly downloadLicenseDisabled = input(false); + readonly cancelSubscriptionDisabled = input(false); + + readonly callToActionClicked = output(); + + protected readonly actions = AdditionalOptionsCardActions; +} diff --git a/libs/subscription/src/components/storage-card/storage-card.component.html b/libs/subscription/src/components/storage-card/storage-card.component.html new file mode 100644 index 00000000000..f8ac4b18604 --- /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..7e06fa23553 --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.mdx @@ -0,0 +1,379 @@ +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) + - [Add Storage Disabled](#add-storage-disabled) + - [Remove Storage Disabled](#remove-storage-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 | +| `addStorageDisabled` | `boolean` | Optional. Disables add storage button when true. Defaults to `false`. | +| `removeStorageDisabled` | `boolean` | Optional. Disables remove storage button when true. Defaults to `false`. | + +### Outputs + +| 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 +- Button disabled states are controlled independently via `addStorageDisabled` and + `removeStorageDisabled` inputs +- 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 progress bar turns red. Button disabled states are controlled +independently via the `addStorageDisabled` and `removeStorageDisabled` inputs. + +### 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 `addStorageDisabled` and `removeStorageDisabled` independently to control button +states during async operations like adding or removing storage. + +### Add Storage Disabled + +Storage card with only the add button disabled: + + + +```html + + +``` + +### Remove Storage Disabled + +Storage card with only the remove button disabled: + + + +```html + + +``` + +## Features + +- **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 +- Use `addStorageDisabled` and `removeStorageDisabled` to control button states during operations +- Show loading states during async storage operations +- Control button states independently based on business logic + +### ❌ Don't + +- Omit the `readableUsed` field - it's required +- Use inconsistent units between `available` and `used` (all should be in GB) +- Allow negative values for storage amounts +- Ignore the `callToActionClicked` events - they require handling +- Display inaccurate or stale storage information +- 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..fe2223f1449 --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.spec.ts @@ -0,0 +1,307 @@ +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("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 add button by default", () => { + setupComponent(baseStorage); + const buttons = fixture.debugElement.queryAll(By.css("button")); + const addButton = buttons[0].nativeElement; + expect(addButton.disabled).toBe(false); + }); + + it("should disable add button when addStorageDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + const addButton = buttons[0]; + expect(addButton.attributes["aria-disabled"]).toBe("true"); + }); + + it("should enable remove button by default", () => { + setupComponent(baseStorage); + const buttons = fixture.debugElement.queryAll(By.css("button")); + const removeButton = buttons[1].nativeElement; + expect(removeButton.disabled).toBe(false); + }); + + it("should disable remove button when removeStorageDisabled is true", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("removeStorageDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + const removeButton = buttons[1]; + expect(removeButton.attributes["aria-disabled"]).toBe("true"); + }); + }); + + describe("independent button disabled states", () => { + it("should disable both buttons independently", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.componentRef.setInput("removeStorageDisabled", 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 both disabled inputs are false", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", false); + fixture.componentRef.setInput("removeStorageDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + + it("should allow add button enabled while remove button disabled", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", false); + fixture.componentRef.setInput("removeStorageDisabled", true); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].nativeElement.disabled).toBe(false); + expect(buttons[1].attributes["aria-disabled"]).toBe("true"); + }); + + it("should allow remove button enabled while add button disabled", () => { + setupComponent(baseStorage); + fixture.componentRef.setInput("addStorageDisabled", true); + fixture.componentRef.setInput("removeStorageDisabled", false); + fixture.detectChanges(); + + const buttons = fixture.debugElement.queryAll(By.css("button")); + expect(buttons[0].attributes["aria-disabled"]).toBe("true"); + expect(buttons[1].nativeElement.disabled).toBe(false); + }); + }); + + describe("button click events", () => { + 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); + + 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..2afbaf0d0b1 --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.stories.ts @@ -0,0 +1,175 @@ +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, + addStorageDisabled: true, + removeStorageDisabled: true, + }, +}; + +export const AddStorageDisabled: Story = { + name: "Add Storage Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + addStorageDisabled: true, + removeStorageDisabled: false, + }, +}; + +export const RemoveStorageDisabled: Story = { + name: "Remove Storage Disabled", + args: { + storage: { + available: 5, + used: 2.5, + readableUsed: "2.5 GB", + } satisfies Storage, + addStorageDisabled: false, + removeStorageDisabled: true, + }, +}; diff --git a/libs/subscription/src/components/storage-card/storage-card.component.ts b/libs/subscription/src/components/storage-card/storage-card.component.ts new file mode 100644 index 00000000000..483649434ff --- /dev/null +++ b/libs/subscription/src/components/storage-card/storage-card.component.ts @@ -0,0 +1,74 @@ +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 const StorageCardActions = { + AddStorage: "add-storage", + RemoveStorage: "remove-storage", +} as const; + +export type StorageCardAction = (typeof StorageCardActions)[keyof typeof StorageCardActions]; + +@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 addStorageDisabled = input(false); + readonly removeStorageDisabled = 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"; + }); + + protected readonly actions = StorageCardActions; +} 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..c9cc6df7263 --- /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` | `SubscriptionCardAction` | Emitted when a user clicks an action button in the callout | + +**SubscriptionCardAction Type:** + +```typescript +type SubscriptionCardAction = + | "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..cdb85360c74 --- /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, + translationKey: "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..32976c89cc2 --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts @@ -0,0 +1,410 @@ +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, + translationKey: "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, + translationKey: "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, + translationKey: "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, + translationKey: "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, + translationKey: "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, + translationKey: "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, + translationKey: "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, + translationKey: "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, + translationKey: "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, + translationKey: "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, + translationKey: "members", + cost: 7, + }, + additionalStorage: { + quantity: 2, + translationKey: "additionalStorageGB", + cost: 0.5, + }, + }, + secretsManager: { + seats: { + quantity: 3, + translationKey: "members", + cost: 13, + }, + additionalServiceAccounts: { + quantity: 5, + translationKey: "additionalServiceAccountsV2", + cost: 1, + }, + }, + discount: { + type: DiscountTypes.PercentOff, + value: 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..ebfb41df6c2 --- /dev/null +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.ts @@ -0,0 +1,278 @@ +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 const SubscriptionCardActions = { + ContactSupport: "contact-support", + ManageInvoices: "manage-invoices", + ReinstateSubscription: "reinstate-subscription", + UpdatePayment: "update-payment", + UpgradePlan: "upgrade-plan", +} as const; + +export type SubscriptionCardAction = + (typeof SubscriptionCardActions)[keyof typeof SubscriptionCardActions]; + +type Badge = { text: string; variant: BadgeVariant }; + +type Callout = Maybe<{ + title: string; + type: CalloutTypes; + icon?: string; + description: string; + callsToAction?: { + text: string; + buttonType: ButtonType; + action: SubscriptionCardAction; + }[]; +}>; + +@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: SubscriptionCardActions.UpdatePayment, + }, + { + text: this.i18nService.t("contactSupportShort"), + buttonType: "unstyled", + action: SubscriptionCardActions.ContactSupport, + }, + ], + }; + } + 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: SubscriptionCardActions.ContactSupport, + }, + ], + }; + } + 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: SubscriptionCardActions.ReinstateSubscription, + }, + ], + }; + } + 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: SubscriptionCardActions.UpgradePlan, + }, + ], + }; + } + 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: SubscriptionCardActions.ManageInvoices, + }, + ], + }; + } + 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: SubscriptionCardActions.ManageInvoices, + }, + ], + }; + } + } + }); + + 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..5c43ed20590 --- /dev/null +++ b/libs/subscription/src/types/bitwarden-subscription.ts @@ -0,0 +1,42 @@ +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; + +export type SubscriptionStatus = (typeof SubscriptionStatuses)[keyof typeof SubscriptionStatuses]; + +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..35df54cb4f2 --- /dev/null +++ b/libs/subscription/src/types/storage.ts @@ -0,0 +1,7 @@ +export const MAX_STORAGE_GB = 100; + +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-core/src/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index 33dde9ae51a..7d28d857403 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -525,6 +525,20 @@ describe("VaultExportService", () => { const exportedData = actual as ExportedVaultAsString; expectEqualFolders(UserFolders, exportedData.data); }); + + it("does not export the key property in unencrypted exports", async () => { + // Create a cipher with a key property + const cipherWithKey = generateCipherView(false); + (cipherWithKey as any).key = "shouldBeDeleted"; + cipherService.getAllDecrypted.mockResolvedValue([cipherWithKey]); + + const actual = await exportService.getExport(userId, "json"); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + const parsed = JSON.parse(exportedData.data); + expect(parsed.items.length).toBe(1); + expect(parsed.items[0].key).toBeUndefined(); + }); }); export class FolderResponse { diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index ddda96b21e0..b30f14872ca 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -317,6 +317,7 @@ export class IndividualVaultExportService const cipher = new CipherWithIdExport(); cipher.build(c); cipher.collectionIds = null; + delete cipher.key; jsonDoc.items.push(cipher); }); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts index ed3a16516f2..8d5178e0e0c 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts @@ -383,6 +383,7 @@ export class OrganizationVaultExportService decCiphers.forEach((c) => { const cipher = new CipherWithIdExport(); cipher.build(c); + delete cipher.key; jsonDoc.items.push(cipher); }); return JSON.stringify(jsonDoc, null, " "); diff --git a/libs/tools/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/add-edit/send-add-edit-dialog.component.ts b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts index 38257df603a..15b50a3809c 100644 --- a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts +++ b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts @@ -6,9 +6,9 @@ import { FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DIALOG_DATA, DialogRef, @@ -44,8 +44,10 @@ export const SendItemDialogResult = Object.freeze({ } as const); /** A result of the Send add/edit dialog. */ -export type SendItemDialogResult = (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult]; - +export type SendItemDialogResult = { + result: (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult]; + send?: SendView; +}; /** * Component for adding or editing a send item. */ @@ -93,7 +95,7 @@ export class SendAddEditDialogComponent { */ async onSendCreated(send: SendView) { // FIXME Add dialogService.open send-created dialog - this.dialogRef.close(SendItemDialogResult.Saved); + this.dialogRef.close({ result: SendItemDialogResult.Saved, send }); return; } @@ -101,14 +103,14 @@ export class SendAddEditDialogComponent { * Handles the event when the send is updated. */ async onSendUpdated(send: SendView) { - this.dialogRef.close(SendItemDialogResult.Saved); + this.dialogRef.close({ result: SendItemDialogResult.Saved }); } /** * Handles the event when the send is deleted. */ async onSendDeleted() { - this.dialogRef.close(SendItemDialogResult.Deleted); + this.dialogRef.close({ result: SendItemDialogResult.Deleted }); this.toastService.showToast({ variant: "success", diff --git a/libs/tools/send/send-ui/src/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..acdb7b56c2b --- /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/types/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..f586373de70 --- /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/types/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/new-send-dropdown/new-send-dropdown.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts index e1474175267..b5cbeced209 100644 --- a/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts +++ b/libs/tools/send/send-ui/src/new-send-dropdown/new-send-dropdown.component.ts @@ -7,7 +7,7 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components"; diff --git a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts index 0859986664a..4f30860b6a6 100644 --- a/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts +++ b/libs/tools/send/send-ui/src/send-form/abstractions/send-form-config.service.ts @@ -1,5 +1,5 @@ -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { Send } from "@bitwarden/common/tools/send/models/domain/send"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; /** diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts index 6724bb324c3..fa069b92ed2 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts @@ -6,9 +6,9 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DialogService, ToastService } from "@bitwarden/components"; import { CredentialGeneratorService } from "@bitwarden/generator-core"; diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index ec351bee923..e2b50eafc99 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -9,8 +9,8 @@ import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SectionComponent, SectionHeaderComponent, diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts index 4e4900039c7..7b00f17cc9c 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-file-details.component.ts @@ -4,9 +4,9 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, Validators, ReactiveFormsModule, FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendFileView } from "@bitwarden/common/tools/send/models/view/send-file.view"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ButtonModule, FormFieldModule, diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts index 0471ed90eef..53a9365bf99 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts @@ -18,8 +18,8 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { AsyncActionsModule, BitSubmitDirective, diff --git a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts index 343fa880795..9178991a028 100644 --- a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts +++ b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts @@ -7,8 +7,8 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendId } from "@bitwarden/common/types/guid"; import { diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts index d885f279bc6..63f4b97105a 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts @@ -10,9 +10,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BadgeModule, ButtonModule, diff --git a/libs/tools/send/send-ui/src/send-list/send-list.component.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/tools/send/send-ui/src/send-table/send-table.component.stories.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts index d2d630b69a2..3a5e3239692 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.stories.ts @@ -1,8 +1,8 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { TableDataSource, I18nMockService } from "@bitwarden/components"; import { SendTableComponent } from "./send-table.component"; diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.ts index c912a01f98a..e46f59bab17 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.ts +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.ts @@ -2,8 +2,8 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BadgeModule, ButtonModule, diff --git a/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts b/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts index ef38938aba8..096ae95ad66 100644 --- a/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts +++ b/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts @@ -4,8 +4,8 @@ import { BehaviorSubject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SendListFiltersService } from "./send-list-filters.service"; diff --git a/libs/tools/send/send-ui/src/services/send-list-filters.service.ts b/libs/tools/send/send-ui/src/services/send-list-filters.service.ts index b266ad08a69..cf84204ba0d 100644 --- a/libs/tools/send/send-ui/src/services/send-list-filters.service.ts +++ b/libs/tools/send/send-ui/src/services/send-list-filters.service.ts @@ -5,8 +5,8 @@ import { FormBuilder } from "@angular/forms"; import { map, Observable, startWith } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { ChipSelectOption } from "@bitwarden/components"; diff --git a/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 }} + + + }
{ const mockCipher = new CipherView(); mockCipher.id = "cipher-id" as CipherId; const mockUserId = "user-id"; + const mockCipherData = { id: mockCipher.id } as CipherData; beforeEach(() => { cipherArchiveService = mock(); @@ -37,8 +39,8 @@ describe("ArchiveCipherUtilitiesService", () => { dialogService.openSimpleDialog.mockResolvedValue(true); passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true); - cipherArchiveService.archiveWithServer.mockResolvedValue(undefined); - cipherArchiveService.unarchiveWithServer.mockResolvedValue(undefined); + cipherArchiveService.archiveWithServer.mockResolvedValue(mockCipherData); + cipherArchiveService.unarchiveWithServer.mockResolvedValue(mockCipherData); i18nService.t.mockImplementation((key) => key); service = new ArchiveCipherUtilitiesService( diff --git a/libs/vault/src/services/archive-cipher-utilities.service.ts b/libs/vault/src/services/archive-cipher-utilities.service.ts index bbe7dba6715..5d3c5c33236 100644 --- a/libs/vault/src/services/archive-cipher-utilities.service.ts +++ b/libs/vault/src/services/archive-cipher-utilities.service.ts @@ -25,11 +25,18 @@ export class ArchiveCipherUtilitiesService { private accountService: AccountService, ) {} - /** Archive a cipher, with confirmation dialog and password reprompt checks. */ - async archiveCipher(cipher: CipherView) { - const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); - if (!repromptPassed) { - return; + /** Archive a cipher, with confirmation dialog and password reprompt checks. + * + * @param cipher The cipher to archive + * @param skipReprompt Whether to skip the password reprompt check + * @returns The archived CipherData on success, or undefined on failure or cancellation + */ + async archiveCipher(cipher: CipherView, skipReprompt = false) { + if (!skipReprompt) { + const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher); + if (!repromptPassed) { + return; + } } const confirmed = await this.dialogService.openSimpleDialog({ @@ -43,38 +50,47 @@ export class ArchiveCipherUtilitiesService { } const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherArchiveService - .archiveWithServer(cipher.id as CipherId, userId) - .then(() => { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("itemWasSentToArchive"), - }); - }) - .catch(() => { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("errorOccurred"), - }); + try { + const cipherResponse = await this.cipherArchiveService.archiveWithServer( + cipher.id as CipherId, + userId, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasSentToArchive"), }); + return cipherResponse; + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + return; + } } - /** Unarchives a cipher */ + /** Unarchives a cipher + * @param cipher The cipher to unarchive + * @returns The unarchived cipher on success, or undefined on failure + */ async unarchiveCipher(cipher: CipherView) { const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - await this.cipherArchiveService - .unarchiveWithServer(cipher.id as CipherId, userId) - .then(() => { - this.toastService.showToast({ - variant: "success", - message: this.i18nService.t("itemWasUnarchived"), - }); - }) - .catch(() => { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("errorOccurred"), - }); + try { + const cipherResponse = await this.cipherArchiveService.unarchiveWithServer( + cipher.id as CipherId, + userId, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemWasUnarchived"), }); + return cipherResponse; + } catch { + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + return; + } } } diff --git a/package-lock.json b/package-lock.json index e2556015113..32d5abebb91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "big-integer": "1.6.52", "braintree-web-drop-in": "1.46.0", "buffer": "6.0.3", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "chalk": "4.1.2", "commander": "14.0.0", "core-js": "3.47.0", @@ -81,7 +81,7 @@ "@electron/notarize": "3.0.1", "@electron/rebuild": "4.0.1", "@eslint/compat": "2.0.0", - "@lit-labs/signals": "0.1.3", + "@lit-labs/signals": "0.2.0", "@ngtools/webpack": "20.3.12", "@nx/devkit": "21.6.10", "@nx/eslint": "21.6.10", @@ -513,6 +513,11 @@ "version": "0.0.0", "license": "GPL-3.0" }, + "libs/auto-confirm": { + "name": "@bitwarden/auto-confirm", + "version": "0.0.1", + "license": "GPL-3.0" + }, "libs/billing": { "name": "@bitwarden/billing", "version": "0.0.0", @@ -4956,6 +4961,10 @@ "resolved": "libs/auth", "link": true }, + "node_modules/@bitwarden/auto-confirm": { + "resolved": "libs/auto-confirm", + "link": true + }, "node_modules/@bitwarden/billing": { "resolved": "libs/billing", "link": true @@ -8785,14 +8794,14 @@ "license": "BSD-3-Clause" }, "node_modules/@lit-labs/signals": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@lit-labs/signals/-/signals-0.1.3.tgz", - "integrity": "sha512-P0yWgH5blwVyEwBg+WFspLzeu1i0ypJP1QB0l1Omr9qZLIPsUu0p4Fy2jshOg7oQyha5n163K3GJGeUhQQ682Q==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@lit-labs/signals/-/signals-0.2.0.tgz", + "integrity": "sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "lit": "^2.0.0 || ^3.0.0", - "signal-polyfill": "^0.2.0" + "signal-polyfill": "^0.2.2" } }, "node_modules/@lit-labs/ssr-dom-shim": { @@ -19312,9 +19321,9 @@ "license": "MIT" }, "node_modules/bufferutil": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", - "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index be6658964a0..7aba2035dce 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@electron/notarize": "3.0.1", "@electron/rebuild": "4.0.1", "@eslint/compat": "2.0.0", - "@lit-labs/signals": "0.1.3", + "@lit-labs/signals": "0.2.0", "@ngtools/webpack": "20.3.12", "@nx/devkit": "21.6.10", "@nx/eslint": "21.6.10", @@ -174,7 +174,7 @@ "big-integer": "1.6.52", "braintree-web-drop-in": "1.46.0", "buffer": "6.0.3", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "chalk": "4.1.2", "commander": "14.0.0", "core-js": "3.47.0", diff --git a/tsconfig.base.json b/tsconfig.base.json index 2f6499eb374..68498cfae01 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -20,48 +20,49 @@ "paths": { "@bitwarden/admin-console/common": ["./libs/admin-console/src/common"], "@bitwarden/angular/*": ["./libs/angular/src/*"], - "@bitwarden/assets": ["libs/assets/src/index.ts"], - "@bitwarden/assets/svg": ["libs/assets/src/svg/index.ts"], + "@bitwarden/assets": ["./libs/assets/src/index.ts"], + "@bitwarden/assets/svg": ["./libs/assets/src/svg/index.ts"], "@bitwarden/auth/angular": ["./libs/auth/src/angular"], "@bitwarden/auth/common": ["./libs/auth/src/common"], + "@bitwarden/auto-confirm": ["libs/auto-confirm/src/index.ts"], "@bitwarden/billing": ["./libs/billing/src"], "@bitwarden/bit-common/*": ["./bitwarden_license/bit-common/src/*"], "@bitwarden/browser/*": ["./apps/browser/src/*"], "@bitwarden/cli/*": ["./apps/cli/src/*"], - "@bitwarden/client-type": ["libs/client-type/src/index.ts"], + "@bitwarden/client-type": ["./libs/client-type/src/index.ts"], "@bitwarden/common/spec": ["./libs/common/spec"], "@bitwarden/common/*": ["./libs/common/src/*"], "@bitwarden/components": ["./libs/components/src"], - "@bitwarden/core-test-utils": ["libs/core-test-utils/src/index.ts"], + "@bitwarden/core-test-utils": ["./libs/core-test-utils/src/index.ts"], "@bitwarden/dirt-card": ["./libs/dirt/card/src"], "@bitwarden/generator-components": ["./libs/tools/generator/components/src"], "@bitwarden/generator-core": ["./libs/tools/generator/core/src"], "@bitwarden/generator-history": ["./libs/tools/generator/extensions/history/src"], "@bitwarden/generator-legacy": ["./libs/tools/generator/extensions/legacy/src"], "@bitwarden/generator-navigation": ["./libs/tools/generator/extensions/navigation/src"], - "@bitwarden/guid": ["libs/guid/src/index.ts"], + "@bitwarden/guid": ["./libs/guid/src/index.ts"], "@bitwarden/importer-core": ["./libs/importer/src"], "@bitwarden/importer-ui": ["./libs/importer/src/components"], "@bitwarden/key-management": ["./libs/key-management/src"], "@bitwarden/key-management-ui": ["./libs/key-management-ui/src"], - "@bitwarden/logging": ["libs/logging/src"], - "@bitwarden/messaging": ["libs/messaging/src/index.ts"], + "@bitwarden/logging": ["./libs/logging/src"], + "@bitwarden/messaging": ["./libs/messaging/src/index.ts"], "@bitwarden/node/*": ["./libs/node/src/*"], - "@bitwarden/nx-plugin": ["libs/nx-plugin/src/index.ts"], + "@bitwarden/nx-plugin": ["./libs/nx-plugin/src/index.ts"], "@bitwarden/platform": ["./libs/platform/src"], "@bitwarden/platform/*": ["./libs/platform/src/*"], - "@bitwarden/pricing": ["libs/pricing/src/index.ts"], + "@bitwarden/pricing": ["./libs/pricing/src/index.ts"], "@bitwarden/send-ui": ["./libs/tools/send/send-ui/src"], - "@bitwarden/serialization": ["libs/serialization/src/index.ts"], - "@bitwarden/state": ["libs/state/src/index.ts"], - "@bitwarden/state-internal": ["libs/state-internal/src/index.ts"], - "@bitwarden/state-test-utils": ["libs/state-test-utils/src/index.ts"], - "@bitwarden/storage-core": ["libs/storage-core/src/index.ts"], - "@bitwarden/storage-test-utils": ["libs/storage-test-utils/src/index.ts"], - "@bitwarden/subscription": ["libs/subscription/src/index.ts"], + "@bitwarden/serialization": ["./libs/serialization/src/index.ts"], + "@bitwarden/state": ["./libs/state/src/index.ts"], + "@bitwarden/state-internal": ["./libs/state-internal/src/index.ts"], + "@bitwarden/state-test-utils": ["./libs/state-test-utils/src/index.ts"], + "@bitwarden/storage-core": ["./libs/storage-core/src/index.ts"], + "@bitwarden/storage-test-utils": ["./libs/storage-test-utils/src/index.ts"], + "@bitwarden/subscription": ["./libs/subscription/src/index.ts"], "@bitwarden/ui-common": ["./libs/ui/common/src"], "@bitwarden/ui-common/setup-jest": ["./libs/ui/common/src/setup-jest"], - "@bitwarden/user-core": ["libs/user-core/src/index.ts"], + "@bitwarden/user-core": ["./libs/user-core/src/index.ts"], "@bitwarden/vault": ["./libs/vault/src"], "@bitwarden/vault-export-core": ["./libs/tools/export/vault-export/vault-export-core/src"], "@bitwarden/vault-export-ui": ["./libs/tools/export/vault-export/vault-export-ui/src"],