diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d1266a174e4..8bb15d37fdf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,13 +8,17 @@ apps/desktop/desktop_native @bitwarden/team-platform-dev apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev -apps/desktop/desktop_native/macos_provider @bitwarden/team-autofill-desktop-dev +apps/desktop/desktop_native/autofill_provider @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-management-dev ## No ownership for Cargo.lock and Cargo.toml to allow dependency updates apps/desktop/desktop_native/Cargo.lock apps/desktop/desktop_native/Cargo.toml +# Web connectors +apps/web/src/connectors @bitwarden/team-auth-dev +apps/web/src/connectors/platform @bitwarden/team-platform-dev + ## Auth team files ## apps/browser/src/auth @bitwarden/team-auth-dev apps/cli/src/auth @bitwarden/team-auth-dev @@ -22,8 +26,6 @@ apps/desktop/src/auth @bitwarden/team-auth-dev apps/web/src/app/auth @bitwarden/team-auth-dev libs/auth @bitwarden/team-auth-dev libs/user-core @bitwarden/team-auth-dev -# web connectors used for auth -apps/web/src/connectors @bitwarden/team-auth-dev bitwarden_license/bit-web/src/app/auth @bitwarden/team-auth-dev libs/angular/src/auth @bitwarden/team-auth-dev libs/common/src/auth @bitwarden/team-auth-dev @@ -84,6 +86,7 @@ apps/web/src/app/billing @bitwarden/team-billing-dev libs/angular/src/billing @bitwarden/team-billing-dev libs/common/src/billing @bitwarden/team-billing-dev libs/billing @bitwarden/team-billing-dev +libs/pricing @bitwarden/team-billing-dev bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev ## Platform team files ## @@ -153,6 +156,9 @@ apps/desktop/macos/autofill-extension @bitwarden/team-autofill-desktop-dev apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/windows_plugin_authenticator @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/autotype @bitwarden/team-autofill-desktop-dev +apps/desktop/desktop_native/napi/src/autofill.rs @bitwarden/team-autofill-desktop-dev +apps/desktop/desktop_native/napi/src/autotype.rs @bitwarden/team-autofill-desktop-dev +apps/desktop/desktop_native/napi/src/sshagent.rs @bitwarden/team-autofill-desktop-dev # DuckDuckGo integration apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-desktop-dev apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-desktop-dev @@ -220,6 +226,9 @@ apps/web/src/locales/en/messages.json **/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre **/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre +# Scanning tools +.checkmarx/ @bitwarden/team-appsec + ## Overrides # For the time being platform owns tsconfig and jest config # These overrides will be removed after Nx is implemented @@ -227,7 +236,9 @@ apps/web/src/locales/en/messages.json **/tsconfig.json @bitwarden/team-platform-dev **/jest.config.js @bitwarden/team-platform-dev **/project.jsons @bitwarden/team-platform-dev -libs/pricing @bitwarden/team-billing-dev +# Platform override specifically for the package-lock.json in +# native-messaging-test-runner so that Platform can manage all lock file updates +apps/desktop/native-messaging-test-runner/package-lock.json @bitwarden/team-platform-dev # Claude related files .claude/ @bitwarden/team-ai-sme diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 1b6522c94dd..718586a9b1a 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -187,6 +187,7 @@ "semver", "serde", "serde_json", + "serde_with", "simplelog", "style-loader", "sysinfo", diff --git a/.github/workflows/alert-ddg-files-modified.yml b/.github/workflows/alert-ddg-files-modified.yml index 35eb0515c10..49918563644 100644 --- a/.github/workflows/alert-ddg-files-modified.yml +++ b/.github/workflows/alert-ddg-files-modified.yml @@ -73,7 +73,7 @@ jobs: _MONITORED_FILES: ${{ steps.changed-files.outputs.monitored_files }} with: script: | - const changedFiles = `$_MONITORED_FILES`.split(' ').filter(file => file.trim() !== ''); + const changedFiles = process.env._MONITORED_FILES.split(' ').filter(file => file.trim() !== ''); const message = ` ⚠️🦆 **DuckDuckGo Integration files have been modified in this PR:** diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 7614fdba396..ef2c91f0a7d 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -152,7 +152,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -260,7 +260,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -392,7 +392,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -565,7 +565,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index d0abe8e12e7..75820c54977 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -130,7 +130,7 @@ jobs: } >> "$GITHUB_ENV" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -326,7 +326,7 @@ jobs: choco install nasm --no-progress - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 701e6208b60..6818064a808 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -183,7 +183,7 @@ jobs: uses: bitwarden/gh-actions/free-disk-space@main - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -236,7 +236,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -339,7 +339,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -399,7 +399,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -487,7 +487,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -562,7 +562,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -755,7 +755,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -827,7 +827,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -1000,14 +1000,14 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.14.2' @@ -1032,14 +1032,14 @@ jobs: - name: Cache Build id: build-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Cache Safari id: safari-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1185,7 +1185,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -1240,14 +1240,14 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.14.2' @@ -1272,14 +1272,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1409,7 +1409,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -1515,14 +1515,14 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.14.2' @@ -1547,14 +1547,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1692,7 +1692,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -1873,7 +1873,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index e626b629f5c..71a2c62ec1a 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -63,6 +63,11 @@ jobs: node_version: ${{ steps.retrieve-node-version.outputs.node_version }} has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: + - name: Log inputs to job summary + uses: bitwarden/ios/.github/actions/log-inputs@main + with: + inputs: "${{ toJson(inputs) }}" + - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: @@ -181,6 +186,19 @@ jobs: ref: ${{ steps.set-server-ref.outputs.server_ref }} persist-credentials: false + - name: Download SDK Artifacts + if: ${{ inputs.sdk_branch != '' }} + uses: bitwarden/gh-actions/download-artifacts@main + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + workflow: build-wasm-internal.yml + workflow_conclusion: success + branch: ${{ inputs.sdk_branch }} + artifacts: sdk-internal + repo: bitwarden/sdk-internal + path: sdk-internal + if_no_artifact_found: fail + - name: Check Branch to Publish env: PUBLISH_BRANCHES: "main,rc,hotfix-rc-web" @@ -204,7 +222,7 @@ jobs: ########## Set up Docker ########## - name: Set up Docker - uses: docker/setup-docker-action@efe9e3891a4f7307e689f2100b33a155b900a608 # v4.5.0 + uses: docker/setup-docker-action@e43656e248c0bd0647d3f5c195d116aacf6fcaf4 # v4.7.0 with: daemon-config: | { @@ -218,7 +236,7 @@ jobs: uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 ########## ACRs ########## - name: Log in to Azure @@ -334,7 +352,7 @@ jobs: - name: Scan Docker image if: ${{ needs.setup.outputs.has_secrets == 'true' }} id: container-scan - uses: anchore/scan-action@568b89d27fc18c60e56937bff480c91c772cd993 # v7.1.0 + uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3 with: image: ${{ steps.image-name.outputs.name }} fail-build: false @@ -390,7 +408,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index c7d80b82baa..b1dc3165c3e 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -58,14 +58,14 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true' - name: Cache NPM id: npm-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: "~/.npm" key: ${{ runner.os }}-npm-chromatic-${{ hashFiles('**/package-lock.json') }} diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index e99034c499a..a707fef0889 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -49,7 +49,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/lint-crowdin-config.yml b/.github/workflows/lint-crowdin-config.yml index dff253a8da2..61e2b3631e6 100644 --- a/.github/workflows/lint-crowdin-config.yml +++ b/.github/workflows/lint-crowdin-config.yml @@ -45,7 +45,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Lint ${{ matrix.app.name }} config - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_PROJECT_ID: ${{ matrix.app.project_id }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 83c931b4fe0..7862c14c186 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -64,7 +64,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -142,7 +142,7 @@ jobs: run: cargo +nightly udeps --workspace --all-features --all-targets - name: Install cargo-deny - uses: taiki-e/install-action@073d46cba2cde38f6698c798566c1b3e24feeb44 # v2.62.67 + uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2.66.7 with: tool: cargo-deny@0.18.6 diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 3a7431c07f0..e468ead4f1e 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index ef287b0de08..5f6ee83e41f 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -216,7 +216,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} registry-url: "https://registry.npmjs.org/" diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index f013abbbb3b..c5db7ea9295 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -331,7 +331,7 @@ jobs: run: wget "https://github.com/bitwarden/clients/releases/download/${_RELEASE_TAG}/macos-build-number.json" - name: Setup Ruby and Install Fastlane - uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 + uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # v1.283.0 with: ruby-version: '3.4.7' bundler-cache: false diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index be0087800f7..c45e249d083 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -182,7 +182,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 65607268cda..33b4df24d7a 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -105,7 +105,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} @@ -485,7 +485,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/sdk-breaking-change-check.yml b/.github/workflows/sdk-breaking-change-check.yml index ecc803ebd5c..eab0dffeda4 100644 --- a/.github/workflows/sdk-breaking-change-check.yml +++ b/.github/workflows/sdk-breaking-change-check.yml @@ -53,7 +53,7 @@ jobs: secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} @@ -76,7 +76,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d543b5287b5..a0f783bbb36 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,14 +14,79 @@ permissions: {} jobs: + typecheck: + name: Run typechecking + runs-on: ubuntu-22.04 + permissions: + contents: read + + steps: + - name: Check out repo + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Get Node Version + id: retrieve-node-version + run: | + NODE_NVMRC=$(cat .nvmrc) + NODE_VERSION=${NODE_NVMRC/v/''} + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" + + - name: Set up Node + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + cache: 'npm' + cache-dependency-path: '**/package-lock.json' + node-version: ${{ steps.retrieve-node-version.outputs.node_version }} + + - name: Print environment + run: | + node --version + npm --version + + - name: Install Node dependencies + run: npm ci + + # We use isolatedModules: true which disables typechecking in tests + # Tests in apps/ are typechecked when their app is built, so we just do it here for libs/ + # See https://bitwarden.atlassian.net/browse/EC-497 + - name: Run typechecking + run: npm run test:types + testing: - name: Run tests + name: Run tests - ${{ matrix.test-group.name }} runs-on: ubuntu-22.04 permissions: checks: write contents: read pull-requests: write + strategy: + fail-fast: false + matrix: + test-group: + - name: Browser + paths: apps/browser bitwarden_license/bit-browser + artifact: jest-coverage-browser + junit: junit-browser.xml + - name: Web + paths: apps/web bitwarden_license/bit-web + artifact: jest-coverage-web + junit: junit-web.xml + - name: Desktop + paths: apps/desktop + artifact: jest-coverage-desktop + junit: junit-desktop.xml + - name: CLI + paths: apps/cli bitwarden_license/bit-cli + artifact: jest-coverage-cli + junit: junit-cli.xml + - name: Libs + paths: libs bitwarden_license/bit-common + artifact: jest-coverage-libs + junit: junit-libs.xml + steps: - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 @@ -50,23 +115,20 @@ jobs: - name: Install Node dependencies run: npm ci - # We use isolatedModules: true which disables typechecking in tests - # Tests in apps/ are typechecked when their app is built, so we just do it here for libs/ - # See https://bitwarden.atlassian.net/browse/EC-497 - - name: Run typechecking - run: npm run test:types - - - name: Run tests + - name: Run tests - ${{ matrix.test-group.name }} # maxWorkers is a workaround for a memory leak that crashes tests in CI: # https://github.com/facebook/jest/issues/9430#issuecomment-1149882002 - run: npm test -- --coverage --maxWorkers=3 + # Reduced to 2 workers and split tests across parallel jobs to prevent OOM kills + run: npm test -- ${{ matrix.test-group.paths }} --coverage --maxWorkers=2 + env: + JEST_JUNIT_OUTPUT_NAME: ${{ matrix.test-group.junit }} - name: Report test results - uses: dorny/test-reporter@7b7927aa7da8b82e81e755810cb51f39941a2cc7 # v2.2.0 + uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: - name: Test Results - path: "junit.xml" + name: Test Results - ${{ matrix.test-group.name }} + path: ${{ matrix.test-group.junit }} reporter: jest-junit fail-on-error: true @@ -78,7 +140,7 @@ jobs: - name: Upload test coverage uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: jest-coverage + name: ${{ matrix.test-group.artifact }} path: ./coverage/lcov.info rust: @@ -183,11 +245,35 @@ jobs: with: persist-credentials: false - - name: Download jest coverage + - name: Download Browser coverage uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - name: jest-coverage - path: ./ + name: jest-coverage-browser + path: ./jest-coverage-browser + + - name: Download Web coverage + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: jest-coverage-web + path: ./jest-coverage-web + + - name: Download Desktop coverage + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: jest-coverage-desktop + path: ./jest-coverage-desktop + + - name: Download CLI coverage + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: jest-coverage-cli + path: ./jest-coverage-cli + + - name: Download Libs coverage + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: jest-coverage-libs + path: ./jest-coverage-libs - name: Download rust coverage uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 @@ -199,5 +285,40 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: files: | - ./lcov.info + ./jest-coverage-browser/lcov.info + ./jest-coverage-web/lcov.info + ./jest-coverage-desktop/lcov.info + ./jest-coverage-cli/lcov.info + ./jest-coverage-libs/lcov.info ./apps/desktop/desktop_native/lcov.info + + run-tests: # Verifies all required tests complete successfully + name: Run tests + runs-on: ubuntu-24.04 + if: always() + needs: + - typecheck + - testing + - rust + - rust-coverage + - upload-codecov + permissions: + contents: read + + steps: + - name: Check job results + env: + NEEDS: ${{ toJSON(needs) }} + run: | + # Print status of all jobs + echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + + # Collect failed jobs + failed_jobs=$(echo "$NEEDS" | jq -r 'to_entries[] | select(.value.result != "success") | .key' | tr '\n' ' ') + + if [ -n "$failed_jobs" ]; then + echo "::error::The following jobs failed:$failed_jobs" + exit 1 + fi + + echo "All required jobs passed successfully!" diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index d66c48fcf58..2aba68c45a9 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -31,7 +31,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/apps/browser/config/base.json b/apps/browser/config/base.json index 02bdc5d22af..0de1da0a648 100644 --- a/apps/browser/config/base.json +++ b/apps/browser/config/base.json @@ -1,7 +1,6 @@ { "devFlags": {}, "flags": { - "accountSwitching": false, "sdk": true } } diff --git a/apps/browser/config/development.json b/apps/browser/config/development.json index 042a98c2c39..12a34d8cbee 100644 --- a/apps/browser/config/development.json +++ b/apps/browser/config/development.json @@ -4,8 +4,5 @@ "base": "https://localhost:8080" }, "skipWelcomeOnInstall": true - }, - "flags": { - "accountSwitching": true } } diff --git a/apps/browser/config/production.json b/apps/browser/config/production.json deleted file mode 100644 index a43eee1d5c9..00000000000 --- a/apps/browser/config/production.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "flags": { - "accountSwitching": true - } -} diff --git a/apps/browser/package.json b/apps/browser/package.json index 7055aabf4fd..745c9d6f3e3 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2025.12.1", + "version": "2026.1.0", "scripts": { "build": "npm run build:chrome", "build:bit": "npm run build:bit:chrome", diff --git a/apps/browser/scripts/package-safari.ps1 b/apps/browser/scripts/package-safari.ps1 index 1df40c68b37..218f7393151 100755 --- a/apps/browser/scripts/package-safari.ps1 +++ b/apps/browser/scripts/package-safari.ps1 @@ -52,7 +52,7 @@ foreach ($subBuildPath in $subBuildPaths) { "--verbose", "--force", "--sign", - "588E3F1724AE018EBA762E42279DAE85B313E3ED", + "A579B6AE496B360642D05B8AB1B650C1B143B770", "--entitlements", $entitlementsPath ) diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index e787876c53d..aad76b885ff 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "تسجيل الدخول باستخدام مفتاح المرور" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "استخدام تسجيل الدخول الأحادي" }, @@ -987,6 +990,12 @@ "no": { "message": "لا" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "الموقع" }, @@ -2045,6 +2054,9 @@ "email": { "message": "البريد الإلكتروني" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "الهاتف" }, @@ -3367,6 +3379,12 @@ "error": { "message": "خطأ" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "خطأ فك التشفير" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 1f87a6046f4..f1bcb822906 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Keçid açarı ilə giriş et" }, + "unlockWithPasskey": { + "message": "Kilidi keçid açarı ilə aç" + }, "useSingleSignOn": { "message": "Vahid daxil olma üsulunu istifadə et" }, @@ -987,6 +990,12 @@ "no": { "message": "Xeyr" }, + "noAuth": { + "message": "Keçidə sahib olan hər kəs" + }, + "anyOneWithPassword": { + "message": "Sizin təyin etdiyiniz parola sahib hər kəs" + }, "location": { "message": "Yerləşmə" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-poçt" }, + "emails": { + "message": "E-poçtlar" + }, "phone": { "message": "Telefon" }, @@ -2474,7 +2486,7 @@ "message": "Element birdəfəlik silindi" }, "archivedItemRestored": { - "message": "Archived item restored" + "message": "Arxivlənmiş element bərpa edildi" }, "restoreItem": { "message": "Elementi bərpa et" @@ -3367,6 +3379,12 @@ "error": { "message": "Xəta" }, + "prfUnlockFailed": { + "message": "Kilid keçid açarı ilə açılmadı. Lütfən yenidən sınayın, ya da başqa kilid açma üsulunu sınayın." + }, + "noPrfCredentialsAvailable": { + "message": "Kilidi açmaq üçün PRF dəstəkli keçid açarı yoxdur. Lütfən əvvəlcə keçid açarı ilə giriş edin." + }, "decryptionError": { "message": "Şifrə açma xətası" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Qoşmanı endir" + }, "downloadBitwarden": { "message": "Bitwarden-i endir" }, @@ -5674,7 +5695,7 @@ "message": "Ekstra enli" }, "narrow": { - "message": "Narrow" + "message": "Dar" }, "sshKeyWrongPassword": { "message": "Daxil etdiyiniz parol yanlışdır." @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Yan naviqasiyanı yeni. ölçüləndir" + }, + "whoCanView": { + "message": "Kimlər baxa bilər" + }, + "specificPeople": { + "message": "Xüsusi insanlar" + }, + "emailVerificationDesc": { + "message": "Bu Send keçidini paylaşdıqdan sonra, bu \"Send\"ə baxması üçün insanlar e-poçtlarını bir kodla doğrulamalıdırlar." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Birdən çox e-poçtu daxil edərkən vergül istifadə edin." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 04e6e4cab52..8ccc68a9b41 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Увайсці з ключом доступу" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Выкарыстаць аднаразовы ўваход" }, @@ -987,6 +990,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Электронная пошта" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Тэлефон" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Памылка" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 4c7bed288be..cbe9a323b53 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Вписване със секретен ключ" }, + "unlockWithPasskey": { + "message": "Отключване със секретен ключ" + }, "useSingleSignOn": { "message": "Използване на еднократна идентификация" }, @@ -987,6 +990,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Всеки с връзката" + }, + "anyOneWithPassword": { + "message": "Всеки с парола, зададена от Вас" + }, "location": { "message": "Местоположение" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Електронна поща" }, + "emails": { + "message": "Е-пощи" + }, "phone": { "message": "Телефон" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Грешка" }, + "prfUnlockFailed": { + "message": "Отключването със секретен ключ не беше успешно. Опитайте отново или използвайте друг начин за отключване." + }, + "noPrfCredentialsAvailable": { + "message": "Няма секретни ключове с включено PRF, налични за отключване. Първо се впишете със секретен ключ." + }, "decryptionError": { "message": "Грешка при дешифриране" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Сваляне на прикачения файл" + }, "downloadBitwarden": { "message": "Сваляне на Битуорден" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Преоразмеряване на страничната навигация" + }, + "whoCanView": { + "message": "Кой може да преглежда" + }, + "specificPeople": { + "message": "Определени хора" + }, + "emailVerificationDesc": { + "message": "След като споделите тази връзка към Изпращане, хората ще трябва да потвърдят е-пощата си чрез код, за да могат да видят това Изпращане." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Можете да въведете повече е-пощи, като ги разделите със запетая." + }, + "emailPlaceholder": { + "message": "потребител@bitwarden.com , потребител@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 306e5ac1c29..866743a9ccf 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "না" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "ই-মেইল" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ফোন" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index dd153c64330..e6d4e8439df 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index ccfb35ce021..f62ffca935f 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Inicieu sessió amb la clau de pas" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Inici de sessió únic" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Ubicació" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Correu electrònic" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telèfon" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Error de desxifrat" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 8ccc9a38221..9fdcce3bf04 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Přihlásit se pomocí přístupového klíče" }, + "unlockWithPasskey": { + "message": "Odemknout pomocí přístupového klíče" + }, "useSingleSignOn": { "message": "Použít jednotné přihlášení" }, @@ -987,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Kdokoli s odkazem" + }, + "anyOneWithPassword": { + "message": "Kdokoli s heslem od Vás" + }, "location": { "message": "Umístění" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-maily" + }, "phone": { "message": "Telefon" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Chyba" }, + "prfUnlockFailed": { + "message": "Nepodařilo se odemknout pomocí přístupového klíče. Zkuste to znovu nebo použijte jinou metodu odemknutí." + }, + "noPrfCredentialsAvailable": { + "message": "K odemknutí nejsou k dispozici žádné přístupové klíče s podporou PRF. Nejprve se přihlaste pomocí hesla." + }, "decryptionError": { "message": "Chyba dešifrování" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Stáhnout přílohu" + }, "downloadBitwarden": { "message": "Stáhnout Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Změnit velikost boční navigace" + }, + "whoCanView": { + "message": "Kdo může zobrazit" + }, + "specificPeople": { + "message": "Vybraní lidé" + }, + "emailVerificationDesc": { + "message": "Po sdílení tohoto odkazu Send budou muset jednotlivci ověřit svůj e-mail pomocí kódu pro zobrazení tohoto Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Zadejte více e-mailů oddělených čárkou." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 627ceda87ba..6d703ca1b5c 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "Na" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lleoliad" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Ebost" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Ffôn" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Gwall" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 7990cec986d..171fc415913 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log ind med adgangsnøgle" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Brug Single Sign-On" }, @@ -987,6 +990,12 @@ "no": { "message": "Nej" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Fejl" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Dekrypteringsfejl" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 40d0156c932..99e195bf194 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Mit Passkey anmelden" }, + "unlockWithPasskey": { + "message": "Mit Passkey entsperren" + }, "useSingleSignOn": { "message": "Single Sign-On verwenden" }, @@ -987,6 +990,12 @@ "no": { "message": "Nein" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Standort" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-Mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Fehler" }, + "prfUnlockFailed": { + "message": "Entsperren mit Passkey fehlgeschlagen. Bitte versuche es erneut oder verwende eine andere Entsperrmethode." + }, + "noPrfCredentialsAvailable": { + "message": "Es sind keine PRF-fähigen Passkeys zum Entsperren verfügbar. Bitte melde dich zuerst mit einem Passkey an." + }, "decryptionError": { "message": "Entschlüsselungsfehler" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Anhang herunterladen" + }, "downloadBitwarden": { "message": "Bitwarden herunterladen" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Größe der Seitennavigation ändern" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 9838ef32bbc..4d94073b4ae 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Σύνδεση με κλειδί πρόσβασης" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Χρήση ενιαίας σύνδεσης" }, @@ -987,6 +990,12 @@ "no": { "message": "Όχι" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Τοποθεσία" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Τηλέφωνο" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Σφάλμα" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Σφάλμα αποκρυπτογράφησης" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Λήψη του Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index dabd238e039..4c36a852f6a 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4601,11 +4619,11 @@ "message": "URI match detection is how Bitwarden identifies autofill suggestions.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, - "regExAdvancedOptionWarning": { + "regExAdvancedOptionWarning": { "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, - "startsWithAdvancedOptionWarning": { + "startsWithAdvancedOptionWarning": { "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, @@ -4613,7 +4631,7 @@ "message": "More about match detection", "description": "Link to match detection docs on warning dialog for advance match strategy" }, - "uriAdvancedOption":{ + "uriAdvancedOption": { "message": "Advanced options", "description": "Advanced option placeholder for uri option component" }, @@ -4803,7 +4821,7 @@ } } }, - "copyFieldCipherName": { + "copyFieldCipherName": { "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { @@ -4835,7 +4853,7 @@ "adminConsole": { "message": "Admin Console" }, - "admin" :{ + "admin": { "message": "Admin" }, "automaticUserConfirmation": { @@ -4844,7 +4862,7 @@ "automaticUserConfirmationHint": { "message": "Automatically confirm pending users while this device is unlocked" }, - "autoConfirmOnboardingCallout":{ + "autoConfirmOnboardingCallout": { "message": "Save time with automatic user confirmation" }, "autoConfirmWarning": { @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -5781,7 +5802,7 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitleV2":{ + "phishingPageTitleV2": { "message": "Phishing attempt detected" }, "phishingPageSummary": { @@ -5801,7 +5822,7 @@ "message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." }, - "phishingPageLearnMore" : { + "phishingPageLearnMore": { "message": "Learn more about phishing detection" }, "protectedBy": { @@ -5969,7 +5990,7 @@ "cardNumberLabel": { "message": "Card number" }, - "removeMasterPasswordForOrgUserKeyConnector":{ + "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, "continueWithLogIn": { @@ -5987,10 +6008,10 @@ "verifyYourOrganization": { "message": "Verify your organization to log in" }, - "organizationVerified":{ + "organizationVerified": { "message": "Organization verified" }, - "domainVerified":{ + "domainVerified": { "message": "Domain verified" }, "leaveOrganizationContent": { @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 2da5ac9e3dd..63cd0f56290 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 04b62e9f880..b02ba84451d 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index ec7030abfdd..fb15597505c 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Iniciar sesión con clave de acceso" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Usar inicio de sesión único" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Ubicación" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Correo electrónico" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Teléfono" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Error de descifrado" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Descargar Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index ab13fc6848d..9623ffafca6 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Logi sisse pääsuvõtmega" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "Ei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoninumber" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Viga" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index b2b27b5fbac..0f614cdb42f 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "Ez" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Emaila" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefonoa" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Akatsa" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 8663553b3bb..e6e5ab0038a 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "با کلید عبور وارد شوید" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "استفاده از ورود تک مرحله‌ای" }, @@ -987,6 +990,12 @@ "no": { "message": "خیر" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "موقعیت" }, @@ -2045,6 +2054,9 @@ "email": { "message": "ایمیل" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "تلفن" }, @@ -3367,6 +3379,12 @@ "error": { "message": "خطا" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "خطای رمزگشایی" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "بارگیری Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 5c6da9a87fb..7587f546afa 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Kirjaudu pääsyavaimella" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Käytä kertakirjautumista" }, @@ -987,6 +990,12 @@ "no": { "message": "En" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sijainti" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Sähköposti" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Puhelinnumero" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Virhe" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Salauksen purkuvirhe" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Lataa Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 52424a32d47..4c906fcd0b6 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "Hindi" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Mag-email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepono" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Mali" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 81c48805014..717bc742fab 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Se connecter avec une clé d'accès" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Utiliser l'authentification unique" }, @@ -987,6 +990,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Emplacement" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Courriel" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Téléphone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Erreur" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Erreur de déchiffrement" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Télécharger Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 2851878948e..3f622477dfe 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Iniciar sesión con Clave de acceso" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Usar inicio de sesión único" }, @@ -987,6 +990,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Correo electrónico" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Teléfono" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Erro" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Erro de descifrado" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 7ffe250a1d4..e2902774bf8 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "כניסה עם מפתח גישה" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "השתמש בכניסה יחידה" }, @@ -437,7 +440,7 @@ "message": "סנכרן" }, "syncNow": { - "message": "Sync now" + "message": "סנכרון כעת" }, "lastSync": { "message": "סנכרון אחרון:" @@ -574,7 +577,7 @@ "message": "הפריט נשלח לארכיון" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "הפריט שוחזר מהארכיב" }, "itemUnarchived": { "message": "הפריט הוסר מהארכיון" @@ -583,19 +586,19 @@ "message": "העבר פריט לארכיון" }, "archiveItemDialogContent": { - "message": "Once archived, this item will be excluded from search results and autofill suggestions." + "message": "עם ארכובו, יהיה הפריט מוחרג מתוצאות החיפוש ומהצעות המילוי האוטומטי." }, "archived": { - "message": "Archived" + "message": "הועבר לארכיב" }, "unarchiveAndSave": { - "message": "Unarchive and save" + "message": "שחזור מהארכיב ושמירה" }, "upgradeToUseArchive": { - "message": "A premium membership is required to use Archive." + "message": "נדרשת חברות פרמיום כדי להשתמש בארכיב." }, "itemRestored": { - "message": "Item has been restored" + "message": "הפריט שוחזר" }, "edit": { "message": "ערוך" @@ -607,7 +610,7 @@ "message": "הצג הכל" }, "showAll": { - "message": "Show all" + "message": "הצגת הכל" }, "viewLess": { "message": "הצג פחות" @@ -987,6 +990,12 @@ "no": { "message": "לא" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "מיקום" }, @@ -1335,19 +1344,19 @@ "message": "ייצא מ־" }, "exportVerb": { - "message": "Export", + "message": "ייצוא", "description": "The verb form of the word Export" }, "exportNoun": { - "message": "Export", + "message": "ייצוא", "description": "The noun form of the word Export" }, "importNoun": { - "message": "Import", + "message": "ייבוא", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "ייבוא", "description": "The verb form of the word Import" }, "fileFormat": { @@ -1429,25 +1438,25 @@ "message": "למידע נוסף" }, "migrationsFailed": { - "message": "An error occurred updating the encryption settings." + "message": "אירעה שגיאה בעת עדכון הגדרות ההצפנה." }, "updateEncryptionSettingsTitle": { - "message": "Update your encryption settings" + "message": "עדכון הגדרות ההצפנה שלך" }, "updateEncryptionSettingsDesc": { - "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + "message": "הגדרות ההצפנה המומלצות החדשות ישפרו את אבטחת החשבון שלך. יש להזין את הסיסמה הראשית שלך כדי לעדכן כעת." }, "confirmIdentityToContinue": { - "message": "Confirm your identity to continue" + "message": "יש לאשר את זהותך כדי להמשיך" }, "enterYourMasterPassword": { - "message": "Enter your master password" + "message": "נא להזין את הסיסמה הראשית שלך" }, "updateSettings": { - "message": "Update settings" + "message": "עדכון ההגדרות" }, "later": { - "message": "Later" + "message": "מאוחר יותר" }, "authenticatorKeyTotp": { "message": "מפתח מאמת (TOTP)" @@ -1480,13 +1489,13 @@ "message": "הצרופה נשמרה" }, "fixEncryption": { - "message": "Fix encryption" + "message": "תיקון ההצפנה" }, "fixEncryptionTooltip": { - "message": "This file is using an outdated encryption method." + "message": "הקובץ מוגדר בשיטת הצפנה לא עדכנית." }, "attachmentUpdated": { - "message": "Attachment updated" + "message": "הצרופה עודכנה" }, "file": { "message": "קובץ" @@ -1498,7 +1507,7 @@ "message": "בחר קובץ" }, "itemsTransferred": { - "message": "Items transferred" + "message": "הפריטים הועברו" }, "maxFileSize": { "message": "גודל הקובץ המרבי הוא 500MB." @@ -1531,7 +1540,7 @@ "message": "1 ג'יגה של מקום אחסון עבור קבצים מצורפים." }, "premiumSignUpStorageV2": { - "message": "$SIZE$ encrypted storage for file attachments.", + "message": "$SIZE$ של אחסון מוצפן עבור קבצים מצורפים.", "placeholders": { "size": { "content": "$1", @@ -1546,13 +1555,13 @@ "message": "אפשרויות כניסה דו־שלבית קנייניות כגון YubiKey ו־Duo." }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "מנוי הפרמיום שלך הסתיים" }, "archivePremiumRestart": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + "message": "לשחזור הגישה לארכיב שלך יש לחדש את מנוי הפרמיום שלך. אם תבצעו עריכת פרטים של פריט בארכיב לפני חידוש המנוי, הפריט ישוחזר אל הכספת שלכם." }, "restartPremium": { - "message": "Restart Premium" + "message": "חידוש מנוי הפרמיום" }, "ppremiumSignUpReports": { "message": "היגיינת סיסמאות, מצב בריאות החשבון, ודיווחים מעודכנים על פרצות חדשות בכדי לשמור על הכספת שלך בטוחה." @@ -1947,7 +1956,7 @@ "message": "שנת תפוגה" }, "monthly": { - "message": "month" + "message": "חודש" }, "expiration": { "message": "תוקף" @@ -2045,6 +2054,9 @@ "email": { "message": "אימייל" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "טלפון" }, @@ -2474,7 +2486,7 @@ "message": "הפריט נמחק לצמיתות" }, "archivedItemRestored": { - "message": "Archived item restored" + "message": "פריט שוחזר מהארכיב" }, "restoreItem": { "message": "שחזר פריט" @@ -3367,6 +3379,12 @@ "error": { "message": "שגיאה" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "שגיאת פענוח" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "הורד את Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 04f966db4d3..6e3db97b75a 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -3,7 +3,7 @@ "message": "bitwarden" }, "appLogoLabel": { - "message": "Bitwarden logo" + "message": "बिटवार्डन लोगो" }, "extName": { "message": "बिटवार्डन पासवर्ड मैनेजर", @@ -26,13 +26,16 @@ "message": "बिटवार्डन का परिचय" }, "logInWithPasskey": { - "message": "Log in with passkey" + "message": "पासकी से लॉग इन करें" + }, + "unlockWithPasskey": { + "message": "पासकी से अनलॉक करें" }, "useSingleSignOn": { "message": "सिंगल साइन-ऑन प्रयोग करें" }, "yourOrganizationRequiresSingleSignOn": { - "message": "Your organization requires single sign-on." + "message": "आपके संगठन को सिंगल साइन-ऑन करना आवश्यक है।" }, "welcomeBack": { "message": "आपका पुन: स्वागत है!" @@ -68,7 +71,7 @@ "message": "मास्टर पासवर्ड संकेत आपको भूल जाने की अवस्था में पासवर्ड को याद करने में सहायता करता है।" }, "masterPassHintText": { - "message": "If you forget your password, the password hint can be sent to your email. $CURRENT$/$MAXIMUM$ character maximum.", + "message": "अगर आप अपना पासवर्ड भूल गए हैं, तो पासवर्ड संकेत आपके ईमेल पर भेजा जा सकता है। $CURRENT$/$MAXIMUM$ अक्षर अधिकतम।", "placeholders": { "current": { "content": "$1", @@ -87,7 +90,7 @@ "message": "Master Password Hint (optional)" }, "passwordStrengthScore": { - "message": "Password strength score $SCORE$", + "message": "पासवर्ड की मज़बूती का स्कोर $SCORE$", "placeholders": { "score": { "content": "$1", @@ -96,10 +99,10 @@ } }, "joinOrganization": { - "message": "Join organization" + "message": "ऑर्गनाइज़ेशन में शामिल हों" }, "joinOrganizationName": { - "message": "Join $ORGANIZATIONNAME$", + "message": "$ORGANIZATIONNAME$ से जुड़ें", "placeholders": { "organizationName": { "content": "$1", @@ -108,7 +111,7 @@ } }, "finishJoiningThisOrganizationBySettingAMasterPassword": { - "message": "Finish joining this organization by setting a master password." + "message": "मास्टर पासवर्ड सेट करके इस ऑर्गनाइज़ेशन से जुड़ने की प्रक्रिया पूरी करें।" }, "tab": { "message": "टैब" @@ -135,7 +138,7 @@ "message": "Copy Password" }, "copyPassphrase": { - "message": "Copy passphrase" + "message": "पासफ़्रेज़ कॉपी करें" }, "copyNote": { "message": "Copy Note" @@ -159,22 +162,22 @@ "message": "कंपनी के नाम को कॉपी करें" }, "copySSN": { - "message": "Copy Social Security number" + "message": "सामाजिक सुरक्षा संख्या या आधारकार्ड संख्या कॉपी करें" }, "copyPassportNumber": { - "message": "Copy passport number" + "message": "पासपोर्ट नंबर कॉपी करें" }, "copyLicenseNumber": { "message": "Copy license number" }, "copyPrivateKey": { - "message": "Copy private key" + "message": "प्राइवेट की कॉपी करें" }, "copyPublicKey": { "message": "Copy public key" }, "copyFingerprint": { - "message": "Copy fingerprint" + "message": "फिंगरप्रिंट कॉपी करें" }, "copyCustomField": { "message": "Copy $FIELD$", @@ -192,11 +195,11 @@ "message": "Copy notes" }, "copy": { - "message": "Copy", + "message": "कॉपी करें", "description": "Copy to clipboard" }, "fill": { - "message": "Fill", + "message": "भरें", "description": "This string is used on the vault page to indicate autofilling. Horizontal space is limited in the interface here so try and keep translations as concise as possible." }, "autoFill": { @@ -212,7 +215,7 @@ "message": "स्वचालित पहचान विवरण" }, "fillVerificationCode": { - "message": "Fill verification code" + "message": "सत्यापन कोड भरें" }, "fillVerificationCodeAria": { "message": "Fill Verification Code", @@ -258,16 +261,16 @@ "message": "Add Item" }, "accountEmail": { - "message": "Account email" + "message": "अकाउंट का ईमेल" }, "requestHint": { - "message": "Request hint" + "message": "संकेत का अनुरोध करें" }, "requestPasswordHint": { - "message": "Request password hint" + "message": "पासवर्ड संकेत का अनुरोध करें" }, "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": { - "message": "Enter your account email address and your password hint will be sent to you" + "message": "अपना अकाउंट ईमेल पता डालें और आपको आपका पासवर्ड संकेत भेज दिया जाएगा" }, "getMasterPasswordHint": { "message": "मास्टर पासवर्ड संकेत प्राप्त करें" @@ -294,7 +297,7 @@ "message": "Change Master Password" }, "continueToWebApp": { - "message": "Continue to web app?" + "message": "वेब ऐप पर जारी रखें?" }, "continueToWebAppDesc": { "message": "Explore more features of your Bitwarden account on the web app." @@ -365,7 +368,7 @@ "message": "Free Bitwarden Families" }, "freeBitwardenFamiliesPageDesc": { - "message": "You are eligible for Free Bitwarden Families. Redeem this offer today in the web app." + "message": "आप फ्री बिटवर्डन फैमिलीज़ के लिए एलिजिबल हैं। इस ऑफर को आज ही वेब ऐप में रिडीम करें।" }, "version": { "message": "संस्करण" @@ -987,6 +990,12 @@ "no": { "message": "नहीं" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "ईमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "फोन" }, @@ -3367,6 +3379,12 @@ "error": { "message": "एरर" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "अटैचमेंट डाउनलोड करें" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 4ff9d75b012..cd5087494f6 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Prijava pristupnim ključem" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Jedinstvena prijava (SSO)" }, @@ -987,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokacija" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Pogreška" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Pogreška pri dešifriranju" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Preuzmi Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index c0da3813fae..4fbedfa9cef 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Bejelentkezés hozzáférési kulccsal" }, + "unlockWithPasskey": { + "message": "Hozzáférési kulcs" + }, "useSingleSignOn": { "message": "Egyszeri bejelentkezés használata" }, @@ -987,6 +990,12 @@ "no": { "message": "Nem" }, + "noAuth": { + "message": "Bárki ezzel a hivatkozással" + }, + "anyOneWithPassword": { + "message": "Bárki az általam beállított jelszóval" + }, "location": { "message": "Hely" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Email címek" + }, "phone": { "message": "Telefonszám" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Hiba" }, + "prfUnlockFailed": { + "message": "Nem sikerült a feloldás a hozzéférési kulccsal. Próbáljuk újra vagy használjunk más feloldási metódust." + }, + "noPrfCredentialsAvailable": { + "message": "A feloldáshoz nem állnak rendelkezésre PRF kompatibilis hozzáférési kucsok. Először jelentkezzünk be egy hozzáférési kulccsal." + }, "decryptionError": { "message": "Visszafejtési hiba" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Melléklet letöltése" + }, "downloadBitwarden": { "message": "Bitwarden letöltése" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Oldalnavigáció átméretezés" + }, + "whoCanView": { + "message": "Ki láthatja" + }, + "specificPeople": { + "message": "Adott személyek" + }, + "emailVerificationDesc": { + "message": "A Send hivatkozás megosztása után a személyeknek ellenőrizniük kell email címüket egy kóddal a Send megtekintéséhez." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Írjunk be több email címet vesszővel elválasztva." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 75e144ea800..3472d26cd01 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Masuk dengan kunci sandi" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Gunakan masuk tunggal" }, @@ -225,7 +228,7 @@ "message": "Salin Nama Kolom Pilihan" }, "noMatchingLogins": { - "message": "Tidak ada info masuk yang cocok." + "message": "Tidak ada log masuk yang cocok" }, "noCards": { "message": "Tanpa kartu" @@ -437,7 +440,7 @@ "message": "Sinkronisasi" }, "syncNow": { - "message": "Sync now" + "message": "Selaraskan sekarang" }, "lastSync": { "message": "Sinkronisasi Terakhir:" @@ -551,27 +554,27 @@ "message": "Atur ulang pencarian" }, "archiveNoun": { - "message": "Archive", + "message": "Arsip", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Arsip", "description": "Verb" }, "unArchive": { "message": "Unarchive" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Butir dalam arsip" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Tidak ada butir dalam arsip" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Butir yang diarsipkan akan muncul di sini dan akan dikecualikan dari hasil pencarian umum dan saran isi otomatis." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "Butir dikirim ke arsip" }, "itemWasUnarchived": { "message": "Item was unarchived" @@ -580,7 +583,7 @@ "message": "Item was unarchived" }, "archiveItem": { - "message": "Archive item" + "message": "Arsipkan butir" }, "archiveItemDialogContent": { "message": "Once archived, this item will be excluded from search results and autofill suggestions." @@ -987,6 +990,12 @@ "no": { "message": "Tidak" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokasi" }, @@ -1477,7 +1486,7 @@ "message": "Tidak ada lampiran." }, "attachmentSaved": { - "message": "Lampiran telah disimpan." + "message": "Lampiran disimpan" }, "fixEncryption": { "message": "Fix encryption" @@ -1495,7 +1504,7 @@ "message": "Berkas untuk dibagikan" }, "selectFile": { - "message": "Pilih berkas." + "message": "Pilih berkas" }, "itemsTransferred": { "message": "Items transferred" @@ -1643,7 +1652,7 @@ "message": "Buka dalam tab baru" }, "webAuthnAuthenticate": { - "message": "Autentikasi dengan WebAuthn." + "message": "Autentikasikan WebAuthn" }, "readSecurityKey": { "message": "Baca kunci keamanan" @@ -1752,7 +1761,7 @@ "message": "URL Server Ikon" }, "environmentSaved": { - "message": "URL dari semua lingkungan telah disimpan." + "message": "URL lingkungan disimpan" }, "showAutoFillMenuOnFormFields": { "message": "Tampilkan menu isi otomatis pada kolom formulir", @@ -1852,7 +1861,7 @@ "message": "Pelajari lebih lanjut tentang isi otomatis" }, "defaultAutoFillOnPageLoad": { - "message": "Konfigurasi autofill standard untuk item login." + "message": "Pengaturan isian otomatis baku bagi butir log masuk" }, "defaultAutoFillOnPageLoadDesc": { "message": "Setelah mengaktifkan Auto-Fill waktu website terbuka, kamu dapat mengaktifkan atau meng-nonaktifkan feature ini untuk setiap item. Ini adalah konfigurasi standard untuk item yang tidak dikonfigurasi terpisah." @@ -1882,7 +1891,7 @@ "message": "Isi otomatis identitas yang terakhir digunakan untuk situs web saat ini" }, "commandGeneratePasswordDesc": { - "message": "Buat dan salin kata sandi acak baru ke papan klip." + "message": "Buat dan salin kata sandi acak baru ke papan klip" }, "commandLockVaultDesc": { "message": "Kunci brankas" @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepon" }, @@ -2501,10 +2513,10 @@ "message": "Item yang Diisi Otomatis dan URI Tersimpan" }, "autoFillSuccess": { - "message": "Item Terisi Otomatis" + "message": "Butir terisi otomatis " }, "insecurePageWarning": { - "message": "Peringatan: Ini adalah halaman HTTP yang tidak aman, dan setiap informasi yang Anda kirim dapat berpotensi terlihat dan diubah oleh orang lain. Login ini awalnya disimpan di halaman aman (HTTPS) " + "message": "Peringatan: Ini adalah halaman HTTP yang tidak aman, dan setiap informasi yang Anda kirim dapat berpotensi terlihat dan diubah oleh orang lain. Log masuk ini awalnya disimpan di halaman aman (HTTPS)." }, "insecurePageWarningFillPrompt": { "message": "Anda masih ingin mengisi login ini?" @@ -3207,7 +3219,7 @@ "message": "Persyaratan kebijakan perusahaan telah diterapkan ke pilihan batas waktu Anda" }, "vaultTimeoutPolicyInEffect": { - "message": "Kebijakan organisasi Anda memengaruhi waktu tunggu brankas Anda. Batas maksimal Waktu Tunggu Brankas yang diizinkan adalah $HOURS$ jam dan $MINUTES$ menit", + "message": "Kebijakan organisasi Anda telah menata waktu tunggu brankas maksimum yang diizinkan milik Anda ke $HOURS$ jam $MINUTES$ menit.", "placeholders": { "hours": { "content": "$1", @@ -3299,7 +3311,7 @@ "message": "Hapus Kata Sandi Utama" }, "removedMasterPassword": { - "message": "Sandi utama dihapus." + "message": "Sandi utama dihapus" }, "leaveOrganizationConfirmation": { "message": "Apakah Anda yakin ingin meninggalkan organisasi ini?" @@ -3367,6 +3379,12 @@ "error": { "message": "Galat" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Kesalahan dekripsi" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Unduh Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index b255b738541..a0eae61b4e4 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Accedi con passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Usa il Single Sign-On" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Luogo" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefono" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Errore" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Errore di decifrazione" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Scarica Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Ridimensiona la navigazione laterale" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 049ca5599d4..bf05a524db9 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "パスキーでログイン" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "シングルサインオンを使用する" }, @@ -987,6 +990,12 @@ "no": { "message": "いいえ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "場所" }, @@ -2045,6 +2054,9 @@ "email": { "message": "メールアドレス" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "電話番号" }, @@ -3367,6 +3379,12 @@ "error": { "message": "エラー" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "復号エラー" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Bitwarden をダウンロード" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 1c25b51696e..1632e52d2f2 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "არა" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "ელ-ფოსტა" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ტელეფონი" }, @@ -3367,6 +3379,12 @@ "error": { "message": "შეცდომა" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index a9fd0f8f2be..4c36a852f6a 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 512ee18fa52..069afdef7d2 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "ಇಲ್ಲ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "ಇಮೇಲ್" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ಫೋನ್‌" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index ae7b92faab6..d4ec9c8aab4 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "패스키를 사용하여 로그인하기" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "통합인증(SSO) 사용하기" }, @@ -987,6 +990,12 @@ "no": { "message": "아니오" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "이메일" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "전화번호" }, @@ -3367,6 +3379,12 @@ "error": { "message": "오류" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 7e1b9ddb49a..ee51489cdaa 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Prisijungti naudojant prieigos raktą" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Naudoti vieningo prisijungimo sistemą" }, @@ -987,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "El. paštas" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefonas" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Klaida" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index cc887c6e9a3..26460353ac3 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Pieteikties ar piekļuves atslēgu" }, + "unlockWithPasskey": { + "message": "Atslēgt ar piekļuves atslēgu" + }, "useSingleSignOn": { "message": "Izmantot vienoto pieteikšanos" }, @@ -987,6 +990,12 @@ "no": { "message": "Nē" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Atrašanās vieta" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-pasts" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Tālrunis" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Kļūda" }, + "prfUnlockFailed": { + "message": "Neizdevās atslēgt ar piekļuves atslēgu. Lūgums mēģināt vēlreiz vai izmantot citu atslēgšanas veidu." + }, + "noPrfCredentialsAvailable": { + "message": "Atslēgšanai nav pieejama neviena PRF iespējota piekļuves atslēga. Lūgums vispirms pieteikties ar piekļuves atslēgu." + }, "decryptionError": { "message": "Atšifrēšanas kļūda" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Lejupielādēt pielikumu" + }, "downloadBitwarden": { "message": "Lejupielādē Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Mainīt sānu pārvietošanās joslas izmēru" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 8676ae8dcd7..678a10073ff 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "തെറ്റ്" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "ഇമെയിൽ" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ഫോൺ" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index ec5e5b84f9a..aeffb274db4 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index a9fd0f8f2be..4c36a852f6a 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index d3164c3cba0..8ecd57508d5 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Logg inn med passnøkkel" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Bruk singulær pålogging" }, @@ -987,6 +990,12 @@ "no": { "message": "Nei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sted" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Feil" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Dekrypteringsfeil" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Last ned Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index a9fd0f8f2be..4c36a852f6a 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 5dd1dbdf059..b3875cf07e3 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Inloggen met passkey" }, + "unlockWithPasskey": { + "message": "Ontgrendelen met passkey" + }, "useSingleSignOn": { "message": "Single sign-on gebruiken" }, @@ -987,6 +990,12 @@ "no": { "message": "Nee" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Locatie" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-mailadres" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoonnummer" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Fout" }, + "prfUnlockFailed": { + "message": "Ontgrendelen met passkey mislukt. Probeer het opnieuw of gebruik een andere ontgrendelingsmethode." + }, + "noPrfCredentialsAvailable": { + "message": "Er zijn geen PRF-ingeschakelde passkeys beschikbaar om te ontgrendelen. Log eerst in met een passkey." + }, "decryptionError": { "message": "Ontsleutelingsfout" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Bijlage downloaden" + }, "downloadBitwarden": { "message": "Bitwarden downloaden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Formaat zijnavigatie wijzigen" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index a9fd0f8f2be..4c36a852f6a 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index a9fd0f8f2be..4c36a852f6a 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 4163f420db1..7979214b3e9 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Logowanie kluczem dostępu" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Użyj logowania jednokrotnego" }, @@ -987,6 +990,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokalizacja" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Adres e-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Numer telefonu" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Błąd" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Błąd odszyfrowywania" }, @@ -4836,7 +4854,7 @@ "message": "Konsola administratora" }, "admin": { - "message": "Admin" + "message": "Administrator" }, "automaticUserConfirmation": { "message": "Automatic user confirmation" @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Pobierz załącznik" + }, "downloadBitwarden": { "message": "Pobierz Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Zmień rozmiar nawigacji bocznej" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 2fbd7dfccbd..5d041cdb9bb 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Conectar-se com chave de acesso" }, + "unlockWithPasskey": { + "message": "Desbloquear com chave de acesso" + }, "useSingleSignOn": { "message": "Usar autenticação única" }, @@ -987,6 +990,12 @@ "no": { "message": "Não" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Localização" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Erro" }, + "prfUnlockFailed": { + "message": "Falha no desbloqueio com a chave de acesso. Tente novamente ou use outro método de desbloqueio." + }, + "noPrfCredentialsAvailable": { + "message": "Nenhuma chave de acesso com PRF está disponível para desbloqueio. Conecte-se com uma chave de acesso primeiro." + }, "decryptionError": { "message": "Erro de descriptografia" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Baixar anexo" + }, "downloadBitwarden": { "message": "Baixar o Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Redimensionar navegação lateral" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 4c2e115ddd5..a5c30c75fc4 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Iniciar sessão com a chave de acesso" }, + "unlockWithPasskey": { + "message": "Desbloquear com chave de acesso" + }, "useSingleSignOn": { "message": "Utilizar início de sessão único" }, @@ -987,6 +990,12 @@ "no": { "message": "Não" }, + "noAuth": { + "message": "Qualquer pessoa com o link" + }, + "anyOneWithPassword": { + "message": "Qualquer pessoa com uma palavra-passe definida por si" + }, "location": { "message": "Localização" }, @@ -1552,7 +1561,7 @@ "message": "Para recuperar o acesso ao seu arquivo, reinicie a sua subscrição Premium. Se editar os detalhes de um item arquivado antes de reiniciar, ele será movido de volta para o seu cofre." }, "restartPremium": { - "message": "Reiniciar Premium" + "message": "Reiniciar o Premium" }, "ppremiumSignUpReports": { "message": "Higiene de palavras-passe, saúde da conta e relatórios de violação de dados para manter o seu cofre seguro." @@ -2045,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-mails" + }, "phone": { "message": "Telefone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Erro" }, + "prfUnlockFailed": { + "message": "Não foi possível desbloquear com a chave de acesso. Por favor, tente novamente ou utilize outro método de desbloqueio." + }, + "noPrfCredentialsAvailable": { + "message": "Não estão disponíveis chaves de acesso com PRF ativado para o desbloqueio. Por favor, inicie sessão primeiro com uma chave de acesso." + }, "decryptionError": { "message": "Erro de desencriptação" }, @@ -4058,7 +4076,7 @@ } }, "inputMinValue": { - "message": "O valor do campo tem de ser, pelo menos, $MIN$ caracteres.", + "message": "O valor introduzido deve ser, no mínimo, $MIN$.", "placeholders": { "min": { "content": "$1", @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Transferir anexo" + }, "downloadBitwarden": { "message": "Descarregar o Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Redimensionar navegação lateral" + }, + "whoCanView": { + "message": "Quem pode ver" + }, + "specificPeople": { + "message": "Pessoas específicas" + }, + "emailVerificationDesc": { + "message": "Após partilhar este Send através do link, os indivíduos terão de verificar o e-mail com um código para poderem ver este Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Introduza vários e-mails, separados por vírgula." + }, + "emailPlaceholder": { + "message": "utilizador@bitwarden.com , utilizador@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 3f80db9688a..8f9f9273d96 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Autentificare cu parolă" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Autentificare unică" }, @@ -987,6 +990,12 @@ "no": { "message": "Nu" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Eroare" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 2d08ed120df..bbb507743da 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Войти с passkey" }, + "unlockWithPasskey": { + "message": "Разблокировать при помощи passkey" + }, "useSingleSignOn": { "message": "Использовать единый вход" }, @@ -987,6 +990,12 @@ "no": { "message": "Нет" }, + "noAuth": { + "message": "Любой, у кого есть ссылка" + }, + "anyOneWithPassword": { + "message": "Любой, у кого есть установленный вами пароль" + }, "location": { "message": "Местоположение" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Ошибка" }, + "prfUnlockFailed": { + "message": "Не удалось разблокировать с помощью passkey. Пожалуйста, повторите попытку или используйте другой метод разблокировки." + }, + "noPrfCredentialsAvailable": { + "message": "Для разблокировки недоступны passkeys с поддержкой PRF. Пожалуйста, сначала авторизуйтесь, используя passkey." + }, "decryptionError": { "message": "Ошибка расшифровки" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Скачать вложение" + }, "downloadBitwarden": { "message": "Скачать Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Изменить размер боковой навигации" + }, + "whoCanView": { + "message": "Кто может просматривать" + }, + "specificPeople": { + "message": "Конкретные пользователи" + }, + "emailVerificationDesc": { + "message": "После того, как вы поделитесь ссылкой на Send, пользователю нужно будет подтвердить свой email кодом, чтобы просмотреть эту Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Введите несколько email, разделяя их запятой." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index b242feae38a..e20a2b6f4f1 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "නැත" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "ඊ-තැපැල්" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "දුරකථන" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 1b995911d87..ced5f612935 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Prihlásiť sa s prístupovým kľúčom" }, + "unlockWithPasskey": { + "message": "Odomknúť pomocou prístupového kľúča" + }, "useSingleSignOn": { "message": "Použiť jednotné prihlásenie" }, @@ -987,6 +990,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Ktokoľvek s odkazom" + }, + "anyOneWithPassword": { + "message": "Ktokoľvek s heslom od vás" + }, "location": { "message": "Poloha" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "E-maily" + }, "phone": { "message": "Telefón" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Chyba" }, + "prfUnlockFailed": { + "message": "Odomknutie pomocou prístupového kľúča zlyhalo. Skúste to znovu alebo použite inú metódu odomknutia." + }, + "noPrfCredentialsAvailable": { + "message": "Na odomkntuie nie sú k dispozícii žiadne PRF-enabled prístupové kľúče. Najskôr sa prihláste pomocou prístupového kľúča." + }, "decryptionError": { "message": "Chyba dešifrovania" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Stiahnuť prílohu" + }, "downloadBitwarden": { "message": "Stiahnuť Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Zmeniť veľkosť bočnej navigácie" + }, + "whoCanView": { + "message": "Kto môže zobraziť" + }, + "specificPeople": { + "message": "Konkrétne osoby" + }, + "emailVerificationDesc": { + "message": "Po zdieľaní tohto odkazu na Send budú musieť jednotlivci overiť svoju e-mailovú adresu pomocou kódu na zobrazenie tohto Sendu." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Zadajte viacero e-mailových adries oddelených čiarkou." + }, + "emailPlaceholder": { + "message": "pouzivate@bitwarden.com, pouzivatel@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 214b1949b9d..435aa10a360 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Napaka" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4938,7 +4956,7 @@ "message": "Organization is deactivated" }, "owner": { - "message": "Owner" + "message": "Lastnik" }, "selfOwnershipLabel": { "message": "You", @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index eac015f3cbf..5340197f8b1 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Пријавите се са приступним кључем" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Употребити једнократну пријаву" }, @@ -987,6 +990,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Локација" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Имејл" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Грешка" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Грешка при декрипцији" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Преузети Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index d5eb1c5149b..c0c4ac3a066 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Logga in med nyckel" }, + "unlockWithPasskey": { + "message": "Lås upp med lösennyckel" + }, "useSingleSignOn": { "message": "Använd Single Sign-On" }, @@ -987,6 +990,12 @@ "no": { "message": "Nej" }, + "noAuth": { + "message": "Vem som helst med länken" + }, + "anyOneWithPassword": { + "message": "Alla som har ett lösenord inställt av dig" + }, "location": { "message": "Plats" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "E-post" + }, "phone": { "message": "Telefon" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Fel" }, + "prfUnlockFailed": { + "message": "Det gick inte att låsa upp med lösennyckel. Försök igen eller använd en annan upplåsningsmetod." + }, + "noPrfCredentialsAvailable": { + "message": "Inga PRF-aktiverade lösennycklar finns tillgängliga för upplåsning. Logga in med en lösennyckel först." + }, "decryptionError": { "message": "Dekrypteringsfel" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Ladda ned bilaga" + }, "downloadBitwarden": { "message": "Ladda ner Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Ändra storlek på sidnavigering" + }, + "whoCanView": { + "message": "Vem kan se" + }, + "specificPeople": { + "message": "Specifika personer" + }, + "emailVerificationDesc": { + "message": "Efter att ha delat denna Send-länk kommer individer att behöva verifiera sin e-post med en kod för att visa denna Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Ange flera e-postadresser genom att separera dem med kommatecken." + }, + "emailPlaceholder": { + "message": "användare@bitwarden.com , användare@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index dea81448f5e..640fdc4893b 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "பாஸ்கீயுடன் உள்நுழையவும்" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "ஒற்றை உள்நுழைவைப் பயன்படுத்தவும்" }, @@ -987,6 +990,12 @@ "no": { "message": "இல்லை" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "இருப்பிடம்" }, @@ -2045,6 +2054,9 @@ "email": { "message": "மின்னஞ்சல்" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "தொலைபேசி" }, @@ -3367,6 +3379,12 @@ "error": { "message": "பிழை" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "குறியாக்கம் நீக்கப் பிழை" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Bitwarden-ஐப் பதிவிறக்கு" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index a9fd0f8f2be..4c36a852f6a 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -987,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 51add21b5a2..737f379f7c3 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "เข้าสู่ระบบด้วยพาสคีย์" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "ใช้การลงชื่อเข้าใช้แบบ SSO" }, @@ -987,6 +990,12 @@ "no": { "message": "ไม่" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "ตำแหน่งที่ตั้ง" }, @@ -2045,6 +2054,9 @@ "email": { "message": "อีเมล" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "โทรศัพท์" }, @@ -3367,6 +3379,12 @@ "error": { "message": "ข้อผิดพลาด" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "ข้อผิดพลาดในการถอดรหัส" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "ดาวน์โหลด Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 25f9e8ad706..838d2b4944c 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Geçiş anahtarıyla giriş yap" }, + "unlockWithPasskey": { + "message": "Kilidi geçiş anahtarıyla aç" + }, "useSingleSignOn": { "message": "Çoklu oturum açma kullan" }, @@ -987,6 +990,12 @@ "no": { "message": "Hayır" }, + "noAuth": { + "message": "Bağlantıya sahip olan herkes" + }, + "anyOneWithPassword": { + "message": "Belirlediğiniz parolaya sahip olan herkes" + }, "location": { "message": "Konum" }, @@ -2045,6 +2054,9 @@ "email": { "message": "E-posta" }, + "emails": { + "message": "E-postalar" + }, "phone": { "message": "Telefon" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Hata" }, + "prfUnlockFailed": { + "message": "Kilit geçiş anahtarıyla açılamadı. Lütfen yeniden deneyin veya başka bir kilit açma yöntemi kullanın." + }, + "noPrfCredentialsAvailable": { + "message": "Kilit açma için PRF uyumlu bir geçiş anahtarı bulunamadı. Lütfen önce bir geçiş anahtarıyla giriş yapın." + }, "decryptionError": { "message": "Şifre çözme sorunu" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Ek dosyayı indir" + }, "downloadBitwarden": { "message": "Bitwarden’ı indirin" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Kenar menüsünü yeniden boyutlandır" + }, + "whoCanView": { + "message": "Kim görebilir" + }, + "specificPeople": { + "message": "Belirli kişiler" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "E-posta adreslerini virgülle ayırarak yazın." + }, + "emailPlaceholder": { + "message": "kullanici@bitwarden.com , kullanici@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 02c6d0ca3a6..4fded2eb53f 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Увійти з ключем доступу" }, + "unlockWithPasskey": { + "message": "Розблокувати з ключем доступу" + }, "useSingleSignOn": { "message": "Використати єдиний вхід" }, @@ -583,7 +586,7 @@ "message": "Архівувати запис" }, "archiveItemDialogContent": { - "message": "Once archived, this item will be excluded from search results and autofill suggestions." + "message": "Після архівації цей запис буде виключено з результатів пошуку і пропозицій автозаповнення." }, "archived": { "message": "Архівовано" @@ -987,6 +990,12 @@ "no": { "message": "Ні" }, + "noAuth": { + "message": "Будь-хто з посиланням" + }, + "anyOneWithPassword": { + "message": "Будь-хто зі встановленим вами паролем" + }, "location": { "message": "Розташування" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Е-пошта" }, + "emails": { + "message": "Е-пошти" + }, "phone": { "message": "Телефон" }, @@ -2474,7 +2486,7 @@ "message": "Запис остаточно видалено" }, "archivedItemRestored": { - "message": "Archived item restored" + "message": "Архівований запис відновлено" }, "restoreItem": { "message": "Відновити запис" @@ -3367,6 +3379,12 @@ "error": { "message": "Помилка" }, + "prfUnlockFailed": { + "message": "Не вдалося розблокувати за допомогою ключа доступу. Повторіть спробу або скористайтеся іншим способом розблокування." + }, + "noPrfCredentialsAvailable": { + "message": "Немає ключів доступу з підтримкою PRF, доступних для розблокування. Спочатку увійдіть з ключем доступу." + }, "decryptionError": { "message": "Помилка розшифрування" }, @@ -4743,7 +4761,7 @@ } }, "moreOptionsLabelNoPlaceholder": { - "message": "More options" + "message": "Більше опцій" }, "moreOptionsTitle": { "message": "Інші можливості – $ITEMNAME$", @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Завантажити вкладення" + }, "downloadBitwarden": { "message": "Завантажити Bitwarden" }, @@ -5132,10 +5153,10 @@ } }, "showMatchDetectionNoPlaceholder": { - "message": "Show match detection" + "message": "Показати виявлення збігів" }, "hideMatchDetectionNoPlaceholder": { - "message": "Hide match detection" + "message": "Приховати виявлення збігів" }, "autoFillOnPageLoad": { "message": "Автоматично заповнювати під час завантаження сторінки?" @@ -5674,7 +5695,7 @@ "message": "Дуже широке" }, "narrow": { - "message": "Narrow" + "message": "Вузький" }, "sshKeyWrongPassword": { "message": "Ви ввели неправильний пароль." @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Змінити розмір бічної панелі" + }, + "whoCanView": { + "message": "Хто може переглядати" + }, + "specificPeople": { + "message": "Певні люди" + }, + "emailVerificationDesc": { + "message": "Після того, як ви поділитеся цим посиланням на відправлення, особам необхідно буде підтвердити свої е-пошти за допомогою коду, щоб переглянути це відправлення." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Введіть декілька адрес е-пошти, розділяючи їх комою." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index d2a774782c9..ca6680c7d6f 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Đăng nhập bằng khóa truy cập" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Dùng đăng nhập một lần" }, @@ -987,6 +990,12 @@ "no": { "message": "Không" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Vị trí" }, @@ -2045,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Số điện thoại" }, @@ -3367,6 +3379,12 @@ "error": { "message": "Lỗi" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Lỗi giải mã" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Tải xuống Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Thay đổi kích thước thanh bên" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 40f1737574e..7ffb2c444e6 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "使用通行密钥登录" }, + "unlockWithPasskey": { + "message": "使用通行密钥解锁" + }, "useSingleSignOn": { "message": "使用单点登录" }, @@ -987,6 +990,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "位置" }, @@ -1843,7 +1852,7 @@ "message": "网页加载时如果检测到登录表单,则执行自动填充。" }, "experimentalFeature": { - "message": "不完整或不信任的网站可以利用页面加载时的自动填充功能。" + "message": "被攻破或不受信任的网站可能会利用页面加载时的自动填充功能。" }, "learnMoreAboutAutofillOnPageLoadLinkText": { "message": "进一步了解风险" @@ -2045,6 +2054,9 @@ "email": { "message": "电子邮箱" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "电话" }, @@ -2240,7 +2252,7 @@ } }, "passwordSafe": { - "message": "没有在已知的数据泄露中发现此密码,它暂时比较安全。" + "message": "在任何已知的数据泄露中均未发现此密码。它暂时比较安全。" }, "baseDomain": { "message": "基础域名", @@ -3051,11 +3063,11 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendExpiresInHoursSingle": { - "message": "在接下来的 1 小时内,任何人都可以通过链接访问此 Send。", + "message": "在接下来的 1 小时内,拥有此链接的任何人都可以访问此 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendExpiresInHours": { - "message": "在接下来的 $HOURS$ 小时内,任何人都可以通过链接访问此 Send。", + "message": "在接下来的 $HOURS$ 小时内,拥有此链接的任何人都可以访问此 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "hours": { @@ -3065,11 +3077,11 @@ } }, "sendExpiresInDaysSingle": { - "message": "在接下来的 1 天内,任何人都可以通过链接访问此 Send。", + "message": "在接下来的 1 天内,拥有此链接的任何人都可以访问此 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendExpiresInDays": { - "message": "在接下来的 $DAYS$ 天内,任何人都可以通过链接访问此 Send。", + "message": "在接下来的 $DAYS$ 天内,拥有此链接的任何人都可以访问此 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "days": { @@ -3155,10 +3167,10 @@ "message": "更新主密码" }, "updateMasterPasswordWarning": { - "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "updateWeakMasterPasswordWarning": { - "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "tdeDisabledMasterPasswordRequired": { "message": "您的组织禁用了信任设备加密。要访问您的密码库,请设置一个主密码。" @@ -3367,6 +3379,12 @@ "error": { "message": "错误" }, + "prfUnlockFailed": { + "message": "使用通行密钥解锁失败。请重试或使用其他解锁方式。" + }, + "noPrfCredentialsAvailable": { + "message": "没有可用于解锁的 PRF 通行密钥。请先使用通行密钥登录。" + }, "decryptionError": { "message": "解密错误" }, @@ -3381,7 +3399,7 @@ "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { - "message": "以避免额外的数据丢失。", + "message": "以避免进一步的数据丢失。", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "generateUsername": { @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "下载附件" + }, "downloadBitwarden": { "message": "下载 Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "调整侧边导航栏大小" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index b38b101efa9..452a04fc091 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "使用密碼金鑰登入" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "使用單一登入" }, @@ -987,6 +990,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "位置" }, @@ -2045,6 +2054,9 @@ "email": { "message": "電子郵件" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "電話號碼" }, @@ -3367,6 +3379,12 @@ "error": { "message": "錯誤" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "解密發生錯誤" }, @@ -4992,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "下載 Bitwarden" }, @@ -6108,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "調整側邊欄大小" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.html b/apps/browser/src/auth/popup/account-switching/account-switcher.component.html index cef2a748d58..0a9e2a1dd9d 100644 --- a/apps/browser/src/auth/popup/account-switching/account-switcher.component.html +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.html @@ -7,13 +7,16 @@ - + -
+
- +

{{ "availableAccounts" | i18n }}

diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts index d7d3c02ab14..ae7f66a9018 100644 --- a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts @@ -1,7 +1,7 @@ import { CommonModule, Location } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { Subject, firstValueFrom, map, of, startWith, switchMap } from "rxjs"; +import { Observable, Subject, firstValueFrom, map, of, startWith, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { LockService, LogoutService } from "@bitwarden/auth/common"; @@ -24,7 +24,6 @@ import { TypographyModule, } from "@bitwarden/components"; -import { enableAccountSwitching } from "../../../platform/flags"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; @@ -59,7 +58,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { loading = false; activeUserCanLock = false; - enableAccountSwitching = true; + enableAccountSwitching$: Observable; constructor( private accountSwitcherService: AccountSwitcherService, @@ -72,7 +71,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { private authService: AuthService, private lockService: LockService, private logoutService: LogoutService, - ) {} + ) { + this.enableAccountSwitching$ = this.accountSwitcherService.accountSwitchingEnabled$(); + } get accountLimit() { return this.accountSwitcherService.ACCOUNT_LIMIT; @@ -97,19 +98,21 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { switchMap((accounts) => { // If account switching is disabled, don't show the lock all button // as only one account should be shown. - if (!enableAccountSwitching()) { - return of(false); - } + return this.accountSwitcherService.accountSwitchingEnabled$().pipe( + switchMap((enabled) => { + if (!enabled) { + return of(false); + } - // When there are an inactive accounts provide the option to lock all accounts - // Note: "Add account" is counted as an inactive account, so check for more than one account - return of(accounts.length > 1); + // When there are inactive accounts provide the option to lock all accounts + // Note: "Add account" is counted as an inactive account, so check for more than one account + return of(accounts.length > 1); + }), + ); }), ); async ngOnInit() { - this.enableAccountSwitching = enableAccountSwitching(); - const availableVaultTimeoutActions = await firstValueFrom( this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(), ); diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts index f3be535f00e..13f4a8635df 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts @@ -9,6 +9,7 @@ import { import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Environment, EnvironmentService, @@ -37,6 +38,7 @@ describe("AccountSwitcherService", () => { const environmentService = mock(); const logService = mock(); const authService = mock(); + const configService = mock(); let accountSwitcherService: AccountSwitcherService; @@ -60,6 +62,7 @@ describe("AccountSwitcherService", () => { messagingService, environmentService, logService, + configService, authService, ); }); diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts index 99d2c83283e..0f25ea91c99 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts @@ -7,6 +7,7 @@ import { filter, firstValueFrom, map, + of, switchMap, throwError, timeout, @@ -17,11 +18,14 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { UserId } from "@bitwarden/common/types/guid"; +import { BrowserApi } from "../../../../platform/browser/browser-api"; import { fromChromeEvent } from "../../../../platform/browser/from-chrome-event"; export type AvailableAccount = { @@ -52,6 +56,7 @@ export class AccountSwitcherService { private messagingService: MessagingService, private environmentService: EnvironmentService, private logService: LogService, + private configService: ConfigService, authService: AuthService, ) { this.availableAccounts$ = combineLatest([ @@ -123,6 +128,19 @@ export class AccountSwitcherService { ); } + /* + * PM-5594: This was a compile-time flag (default true) which made an exception for Safari in platform/flags. + * The truthiness of AccountSwitching has been enshrined at this point, so those compile-time flags have been removed + * in favor of this method to allow easier access to the config service for controlling Safari. Unwinding the Safari + * flag should be more straightforward from this consolidation. + */ + accountSwitchingEnabled$(): Observable { + if (BrowserApi.isSafariApi) { + return this.configService.getFeatureFlag$(FeatureFlag.SafariAccountSwitching); + } + return of(true); + } + get specialAccountAddId() { return this.SPECIAL_ADD_ACCOUNT_ID; } diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 6a3378670bf..1789feebe4e 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -257,7 +257,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { pin: await this.pinService.isPinSet(activeAccount.id), pinLockWithMasterPassword: (await this.pinService.getPinLockType(activeAccount.id)) == "EPHEMERAL", - biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(), + biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(activeAccount.id), enableAutoBiometricsPrompt: await firstValueFrom( this.biometricStateService.promptAutomatically$, ), diff --git a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts index a70ffe25310..ae5026c9566 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts @@ -2,6 +2,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { SecurityTask } from "@bitwarden/common/vault/tasks"; import AutofillPageDetails from "../../models/autofill-page-details"; +import { NotificationTypes } from "../../notification/abstractions/notification-bar"; export type NotificationTypeData = { isVaultLocked?: boolean; @@ -17,10 +18,26 @@ export type LoginSecurityTaskInfo = { uri: ModifyLoginCipherFormData["uri"]; }; +/** + * Distinguished from `NotificationTypes` in that this represents the + * pre-resolved notification scenario, vs the notification component + * (e.g. "Add" and "Change" will be removed + * post-`useUndeterminedCipherScenarioTriggeringLogic` migration) + */ +export const NotificationScenarios = { + ...NotificationTypes, + /** represents scenarios handling saving new and updated ciphers after form submit */ + Cipher: "cipher", +} as const; + +export type NotificationScenario = + (typeof NotificationScenarios)[keyof typeof NotificationScenarios]; + export type WebsiteOriginsWithFields = Map>; export type ActiveFormSubmissionRequests = Set; +/** This type represents an expectation of nullish values being represented as empty strings */ export type ModifyLoginCipherFormData = { uri: string; username: string; diff --git a/apps/browser/src/autofill/background/auto-submit-login.background.spec.ts b/apps/browser/src/autofill/background/auto-submit-login.background.spec.ts index 82a907a9e43..01767281a20 100644 --- a/apps/browser/src/autofill/background/auto-submit-login.background.spec.ts +++ b/apps/browser/src/autofill/background/auto-submit-login.background.spec.ts @@ -249,6 +249,20 @@ describe("AutoSubmitLoginBackground", () => { false, ); }); + + it("properly cleans up auto-submit workflows when requestInitiator is falsy but active auto-submit hosts exist", async () => { + webRequestDetails.initiator = undefined; + jest + .spyOn(BrowserApi, "getTab") + .mockResolvedValue(mock({ url: validAutoSubmitUrl, id: 1 })); + + triggerWebRequestOnBeforeRequestEvent(webRequestDetails); + await flushPromises(); + + expect(autoSubmitLoginBackground["validAutoSubmitHosts"].has(validAutoSubmitHost)).toBe( + false, + ); + }); }); describe("when the extension is running on a Safari browser", () => { diff --git a/apps/browser/src/autofill/background/auto-submit-login.background.ts b/apps/browser/src/autofill/background/auto-submit-login.background.ts index f593fab2516..07f0b98318a 100644 --- a/apps/browser/src/autofill/background/auto-submit-login.background.ts +++ b/apps/browser/src/autofill/background/auto-submit-login.background.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { filter, firstValueFrom, of, switchMap } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -64,6 +62,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr this.policyService.policiesByType$(PolicyType.AutomaticAppLogIn, userId), ), getFirstPolicy, + filter((policy): policy is Policy => policy !== undefined), ) .subscribe(this.handleAutoSubmitLoginPolicySubscription.bind(this)); } @@ -165,7 +164,11 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr details: chrome.webRequest.OnBeforeRequestDetails, ): undefined => { const requestInitiator = this.getRequestInitiator(details); - const isValidInitiator = this.isValidInitiator(requestInitiator); + if (!requestInitiator && this.validAutoSubmitHosts.size === 0) { + return; + } + + const isValidInitiator = requestInitiator ? this.isValidInitiator(requestInitiator) : false; if ( this.postRequestEncounteredAfterSubmission(details, isValidInitiator) || @@ -175,14 +178,20 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr return; } - if (isValidInitiator && this.shouldRouteTriggerAutoSubmit(details, requestInitiator)) { + if ( + requestInitiator && + isValidInitiator && + this.shouldRouteTriggerAutoSubmit(details, requestInitiator) + ) { this.setupAutoSubmitFlow(details); return; } - this.disableAutoSubmitFlow(requestInitiator, details).catch((error) => - this.logService.error(error), - ); + if (requestInitiator || this.validAutoSubmitHosts.size > 0) { + this.disableAutoSubmitFlow(requestInitiator || "", details).catch((error) => + this.logService.error(error), + ); + } }; /** @@ -368,8 +377,9 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr } const tab = await BrowserApi.getTab(details.tabId); - if (this.isValidAutoSubmitHost(tab?.url)) { - this.removeUrlFromAutoSubmitHosts(tab.url); + const tabUrl = tab?.url; + if (tabUrl && this.isValidAutoSubmitHost(tabUrl)) { + this.removeUrlFromAutoSubmitHosts(tabUrl); } }; @@ -427,7 +437,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr */ private getUrlHost = (url: string) => { let parsedUrl = url; - if (!parsedUrl) { + if (!parsedUrl || typeof parsedUrl !== "string") { return ""; } @@ -495,6 +505,10 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr message: AutoSubmitLoginMessage, sender: chrome.runtime.MessageSender, ) => { + if (sender.frameId == null || !sender.tab || !message.pageDetails) { + return; + } + await this.autofillService.doAutoFillOnTab( [ { @@ -515,7 +529,9 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * @param sender - The message sender. */ private handleMultiStepAutoSubmitLoginComplete = (sender: chrome.runtime.MessageSender) => { - this.removeUrlFromAutoSubmitHosts(sender.url); + if (sender.url) { + this.removeUrlFromAutoSubmitHosts(sender.url); + } }; /** @@ -526,7 +542,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr */ private async initSafari() { const currentTab = await BrowserApi.getTabFromCurrentWindow(); - if (currentTab) { + if (currentTab?.url && currentTab.id != null && currentTab.id >= 0) { this.setMostRecentIdpHost(currentTab.url, currentTab.id); } @@ -558,7 +574,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr } const tab = await BrowserApi.getTab(activeInfo.tabId); - if (tab) { + if (tab?.url && tab.id != null && tab.id >= 0) { this.setMostRecentIdpHost(tab.url, tab.id); } }; @@ -570,7 +586,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * @param changeInfo - The change information of the tab. */ private handleSafariTabOnUpdated = (tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo) => { - if (changeInfo) { + if (changeInfo.url) { this.setMostRecentIdpHost(changeInfo.url, tabId); } }; @@ -626,13 +642,17 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * @param sender - The message sender. * @param sendResponse - The response callback. */ - private handleExtensionMessage = async ( + private handleExtensionMessage = ( message: AutoSubmitLoginMessage, sender: chrome.runtime.MessageSender, sendResponse: (response?: any) => void, ) => { const { tab, url } = sender; - if (tab?.id !== this.currentAutoSubmitHostData.tabId || !this.isValidAutoSubmitHost(url)) { + if ( + !url || + tab?.id !== this.currentAutoSubmitHostData.tabId || + !this.isValidAutoSubmitHost(url) + ) { return null; } diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index ab16788ea6f..7d33d79a697 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -67,8 +67,10 @@ describe("NotificationBackground", () => { }); const folderService = mock(); const enableChangedPasswordPromptMock$ = new BehaviorSubject(true); + const enableAddedLoginPromptMock$ = new BehaviorSubject(true); const userNotificationSettingsService = mock(); userNotificationSettingsService.enableChangedPasswordPrompt$ = enableChangedPasswordPromptMock$; + userNotificationSettingsService.enableAddedLoginPrompt$ = enableAddedLoginPromptMock$; const domainSettingsService = mock(); const environmentService = mock(); @@ -90,7 +92,9 @@ describe("NotificationBackground", () => { }); beforeEach(() => { - activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Locked); + activeAccountStatusMock$ = new BehaviorSubject( + AuthenticationStatus.Locked as AuthenticationStatus, + ); authService = mock(); authService.activeAccountStatus$ = activeAccountStatusMock$; accountService.activeAccount$ = activeAccountSubject; @@ -290,7 +294,7 @@ describe("NotificationBackground", () => { username: "test", password: "password", uri: "https://example.com", - newPassword: null, + newPassword: "", }; beforeEach(() => { tab = createChromeTabMock(); @@ -323,7 +327,7 @@ describe("NotificationBackground", () => { ...mockModifyLoginCipherFormData, uri: "", }; - activeAccountStatusMock$.next(AuthenticationStatus.Locked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); await notificationBackground.triggerAddLoginNotification(data, tab); @@ -389,14 +393,14 @@ describe("NotificationBackground", () => { password: data.password, }, sender.tab, - true, + true, // will yield an unlock followed by a new password notification ); }); it("adds the login to the queue if the user has an unlocked account and the login is new", async () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, - username: null, + username: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -426,8 +430,8 @@ describe("NotificationBackground", () => { let pushChangePasswordToQueueSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance; const mockModifyLoginCipherFormData: ModifyLoginCipherFormData = { - username: null, - uri: null, + username: "", + uri: "", password: "currentPassword", newPassword: "newPassword", }; @@ -527,7 +531,7 @@ describe("NotificationBackground", () => { ...mockModifyLoginCipherFormData, uri: "https://example.com", password: "newPasswordUpdatedElsewhere", - newPassword: null, + newPassword: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -589,7 +593,7 @@ describe("NotificationBackground", () => { "example.com", data?.newPassword, sender.tab, - true, + true, // will yield an unlock followed by an update password notification ); }); @@ -597,8 +601,8 @@ describe("NotificationBackground", () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", - password: null, - newPassword: null, + password: "", + newPassword: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -637,7 +641,7 @@ describe("NotificationBackground", () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", - password: null, + password: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -665,7 +669,7 @@ describe("NotificationBackground", () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", - password: null, + password: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -686,6 +690,1497 @@ describe("NotificationBackground", () => { }); }); + describe("triggerCipherNotification message handler", () => { + let tab: chrome.tabs.Tab; + let sender: chrome.runtime.MessageSender; + let getEnableChangedPasswordPromptSpy: jest.SpyInstance; + let getEnableAddedLoginPromptSpy: jest.SpyInstance; + let pushChangePasswordToQueueSpy: jest.SpyInstance; + let pushAddLoginToQueueSpy: jest.SpyInstance; + let getAllDecryptedForUrlSpy: jest.SpyInstance; + const mockFormattedURI = "archive.org"; + const mockFormURI = "https://www.archive.org"; + const expectSkippedCheckingNotification = () => { + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + }; + + beforeEach(() => { + tab = createChromeTabMock(); + sender = mock({ tab }); + getEnableAddedLoginPromptSpy = jest.spyOn( + notificationBackground as any, + "getEnableAddedLoginPrompt", + ); + getEnableChangedPasswordPromptSpy = jest.spyOn( + notificationBackground as any, + "getEnableChangedPasswordPrompt", + ); + + pushChangePasswordToQueueSpy = jest.spyOn( + notificationBackground as any, + "pushChangePasswordToQueue", + ); + pushAddLoginToQueueSpy = jest.spyOn(notificationBackground as any, "pushAddLoginToQueue"); + getAllDecryptedForUrlSpy = jest.spyOn(cipherService, "getAllDecryptedForUrl"); + }); + + afterEach(() => { + getEnableAddedLoginPromptSpy.mockRestore(); + getEnableChangedPasswordPromptSpy.mockRestore(); + pushChangePasswordToQueueSpy.mockRestore(); + pushAddLoginToQueueSpy.mockRestore(); + getAllDecryptedForUrlSpy.mockRestore(); + }); + + it("skips checking if a notification should trigger if no fields were filled", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "", + uri: mockFormURI, + username: "", + }; + + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "I<3VogonPoetry", username: "ADent" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the passed url is not valid", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: "", + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "I<3VogonPoetry", username: "ADent" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the user has disabled both the new login and update password notification", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the user is logged out", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if there is no active account", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + accountService.activeAccount$ = new BehaviorSubject(null); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the values for the `password` and `newPassword` fields match (no change)", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Beeblebrox4Prez", + password: "Beeblebrox4Prez", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the vault is locked and there is no value for the `newPassword` field", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "Beeblebrox4Prez", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + describe("when `username` and `password` and `newPassword` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Edro2x", + password: "UShallKnotPassword", + uri: mockFormURI, + username: "gandalfG", + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, // will yield an unlock prompt followed by an update password prompt + ); + }); + + it("and cipher update candidates match `newPassword` only, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and cipher update candidates match `newPassword` only, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `password` only, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "UShallKnotPassword", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `password` only, do not trigger an update cipher notification if the update notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "UShallKnotPassword", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `password` only, as well as `newPassword` only, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "TBombadil" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "UShallKnotPassword", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` only, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-2"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` only, do not trigger an update cipher notification if the update notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` only, as well as `password` or `newPassword` only, trigger an update cipher notification with the candidates `username`", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "BBaggins" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "EdroEdro", username: "gandalfG" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` and `newPassword`, do not trigger an update (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "sting123", username: "BBaggins" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "Edro2x", username: "gandalfG" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` and `newPassword` as well as any other combination of `username`, `password`, and/or `newPassword`, do not trigger an update (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "sting123", username: "BBaggins" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "Edro2x", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-5", + login: { password: "Edro2x", username: "FBaggins" }, + }), + mock({ + id: "cipher-id-6", + login: { password: "UShallKnotPassword", username: "TBombadil" }, + }), + mock({ + id: "cipher-id-7", + login: { password: "ShyerH1re", username: "gandalfG" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` and `password`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` and `password`, do not trigger an update cipher notification if the update notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` AND `password` as well as any OTHER combination of `username`, `password`, and/or `newPassword` (excluding `username` AND `newPassword`), trigger an update cipher notification with the candidates matching `username` AND `password`", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "TBombadil" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "flyUPh00lz", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-5", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-6", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username`, `password`, nor `newPassword`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username`, `password`, nor `newPassword`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when `username` and `newPassword` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "2ndBreakf4st", + password: "", + uri: mockFormURI, + username: "BBaggins", + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, // will yield an unlock followed by an update password notification + ); + }); + + it("and cipher update candidates match only `newPassword`, do not trigger a notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "2ndBreakf4st" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match only `username`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Frodo", password: "differentPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Pippin", password: "2ndBreakf4st" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and at least one cipher update candidate matches both `username` and `newPassword`, do not trigger an update (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "2ndBreakf4st" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Frodo", password: "differentPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `username` nor `newPassword`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "differentPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username` nor `newPassword`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "differentPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when only `username` field is filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "", + uri: mockFormURI, + username: "BBaggins", + }; + + it("and the user vault is locked, do not trigger a notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("and at least one cipher update candidate matches `username`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "password1" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Frodo", password: "password2" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `username`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "password1" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "password2" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: "", + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "password1" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "password2" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when `password` and `newPassword` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "4WzrdIzN0tLa7e", + password: "UShallKnotPassword", + username: "", + uri: mockFormURI, + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, // will yield an unlock followed by an update password notification + ); + }); + + it("and cipher update candidates only match `newPassword`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "GaldalfG", password: "4WzrdIzN0tLa7e" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "GaldalfW", password: "4WzrdIzN0tLa7e" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates only match `password`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Merry", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-2", "cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates only match `password`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Merry", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `password` or `newPassword`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "11sies" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + }); + + describe("when only `password` field is filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "UShallKnotPassword", + uri: mockFormURI, + username: "", + }; + + it("and the user vault is locked, do not trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("and cipher update candidates only match `password`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `password`, trigger an update cipher notification with ALL cipher update candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "MahPr3c10us" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "PTook", password: "f00lOfAT00k" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2", "cipher-id-3"], + mockFormattedURI, + formEntryData.password, + sender.tab, + ); + }); + + it("and no cipher update candidates match `password`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "MahPr3c10us" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "PTook", password: "f00lOfAT00k" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when `username` and `password` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "ShyerH1re", + uri: mockFormURI, + username: "BBaggins", + }; + + it("and cipher update candidates only match `password`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "FrodoB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + mockFormattedURI, + formEntryData.password, + sender.tab, + ); + }); + + it("and cipher update candidates only match `password`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "FrodoB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates only match `username`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "BilboB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + mockFormattedURI, + formEntryData.password, + sender.tab, + ); + }); + + it("and cipher update candidates only match `username`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "BilboB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` and `password`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "BBaggins", password: "ShyerH1re" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` AND `password` and additionally `username` OR `password`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-4", + login: { username: "TBombadil", password: "ShyerH1re" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `username` or `password`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BilboB", password: "PutAR1ngOnIt" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.password, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username` or `password`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BilboB", password: "PutAR1ngOnIt" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when only `newPassword` field is filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "ShyerH1re", + password: "", + uri: mockFormURI, + username: "", + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, // will yield an unlock followed by an update password notification + ); + }); + + it("and cipher update candidates only match `newPassword`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "ShyerH1re" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `newPassword`, trigger an update cipher notification with ALL cipher update candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "SamwiseG", password: "P0t4toes" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2", "cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and no cipher update candidates match `newPassword`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "SamwiseG", password: "P0t4toes" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + }); + describe("bgRemoveTabFromNotificationQueue message handler", () => { it("splices a notification queue item based on the passed tab", async () => { const tab = createChromeTabMock({ id: 2 }); @@ -767,7 +2262,6 @@ describe("NotificationBackground", () => { let createWithServerSpy: jest.SpyInstance; let updateWithServerSpy: jest.SpyInstance; let folderExistsSpy: jest.SpyInstance; - let cipherEncryptSpy: jest.SpyInstance; beforeEach(() => { activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -791,7 +2285,6 @@ describe("NotificationBackground", () => { createWithServerSpy = jest.spyOn(cipherService, "createWithServer"); updateWithServerSpy = jest.spyOn(cipherService, "updateWithServer"); folderExistsSpy = jest.spyOn(notificationBackground as any, "folderExists"); - cipherEncryptSpy = jest.spyOn(cipherService, "encrypt"); accountService.activeAccount$ = activeAccountSubject; }); @@ -1190,13 +2683,7 @@ describe("NotificationBackground", () => { folderExistsSpy.mockResolvedValueOnce(false); convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView); editItemSpy.mockResolvedValueOnce(undefined); - cipherEncryptSpy.mockResolvedValueOnce({ - cipher: { - ...cipherView, - id: "testId", - }, - encryptedFor: userId, - }); + createWithServerSpy.mockResolvedValueOnce(cipherView); sendMockExtensionMessage(message, sender); await flushPromises(); @@ -1205,7 +2692,6 @@ describe("NotificationBackground", () => { queueMessage, null, ); - expect(cipherEncryptSpy).toHaveBeenCalledWith(cipherView, "testId"); expect(createWithServerSpy).toHaveBeenCalled(); expect(tabSendMessageDataSpy).toHaveBeenCalledWith( sender.tab, @@ -1241,13 +2727,6 @@ describe("NotificationBackground", () => { folderExistsSpy.mockResolvedValueOnce(true); convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView); editItemSpy.mockResolvedValueOnce(undefined); - cipherEncryptSpy.mockResolvedValueOnce({ - cipher: { - ...cipherView, - id: "testId", - }, - encryptedFor: userId, - }); const errorMessage = "fetch error"; createWithServerSpy.mockImplementation(() => { throw new Error(errorMessage); @@ -1256,7 +2735,6 @@ describe("NotificationBackground", () => { sendMockExtensionMessage(message, sender); await flushPromises(); - expect(cipherEncryptSpy).toHaveBeenCalledWith(cipherView, "testId"); expect(createWithServerSpy).toThrow(errorMessage); expect(tabSendMessageSpy).not.toHaveBeenCalledWith(sender.tab, { command: "addedCipher", diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 1cbf915b06a..e97672c1f0d 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -22,6 +22,7 @@ import { import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { ProductTierType } from "@bitwarden/common/billing/enums/product-tier-type.enum"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; @@ -79,6 +80,30 @@ import { } from "./abstractions/overlay-notifications.background"; import { OverlayBackgroundExtensionMessage } from "./abstractions/overlay.background"; +const inputScenarios = { + usernamePasswordNewPassword: "usernamePasswordNewPassword", + usernameNewPassword: "usernameNewPassword", + usernamePassword: "usernamePassword", + username: "username", + passwordNewPassword: "passwordNewPassword", + newPassword: "newPassword", + password: "password", +} as const; + +type InputScenarioKey = keyof typeof inputScenarios; +type InputScenario = (typeof inputScenarios)[InputScenarioKey]; + +type CiphersByInputMatchCategory = { + allFieldMatches: CipherView["id"][]; + newPasswordOnlyMatches: CipherView["id"][]; + noFieldMatches: CipherView["id"][]; + passwordNewPasswordMatches: CipherView["id"][]; + passwordOnlyMatches: CipherView["id"][]; + usernameNewPasswordMatches: CipherView["id"][]; + usernameOnlyMatches: CipherView["id"][]; + usernamePasswordMatches: CipherView["id"][]; +}; + export default class NotificationBackground { private openUnlockPopout = openUnlockPopout; private openAddEditVaultItemPopout = openAddEditVaultItemPopout; @@ -152,6 +177,10 @@ export default class NotificationBackground { this.cleanupNotificationQueue(); } + useUndeterminedCipherScenarioTriggeringLogic$ = this.configService.getFeatureFlag$( + FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic, + ); + /** * Gets the enableChangedPasswordPrompt setting from the user notification settings service. */ @@ -292,7 +321,7 @@ export default class NotificationBackground { type: CipherType.Login, reprompt, favorite, - ...(organizationCategories.length ? { organizationCategories } : {}), + ...(organizationCategories.length > 0 ? { organizationCategories } : {}), icon: buildCipherIcon(iconsServerUrl, view, showFavicons), login: login && { username: login.username }, }; @@ -309,7 +338,7 @@ export default class NotificationBackground { activeUserId: UserId, ): Promise { const tasks: SecurityTask[] = await this.getSecurityTasks(activeUserId); - if (!tasks?.length) { + if (!(tasks?.length > 0)) { return null; } @@ -317,7 +346,7 @@ export default class NotificationBackground { modifyLoginData.uri, activeUserId, ); - if (!urlCiphers?.length) { + if (!(urlCiphers?.length > 0)) { return null; } @@ -596,6 +625,216 @@ export default class NotificationBackground { await this.checkNotificationQueue(tab); } + /** + * Receives filled form values and determines if a notification should be + * triggered, and if so, what kind and with what data. + * + * If an update scenario is identified, a change password message is added to the + * notification queue, prompting the user to update a stored login that has changed. + * + * A new cipher notification is triggered in other defined scenarios + * with the user's form input. + * + * Returns `true` or `false` to indicate if such a notification was + * triggered or not. + * + * For the purposes of this function, form field inputs should be assumed to be + * qualified accurately. + */ + async triggerCipherNotification( + data: ModifyLoginCipherFormData, + tab: chrome.tabs.Tab, + ): Promise { + const usernameFieldValue: string | null = data.username || null; + const currentPasswordFieldValue = data.password || null; + const newPasswordFieldValue = data.newPassword || null; + + // If no values were entered, exit early + if (!usernameFieldValue && !currentPasswordFieldValue && !newPasswordFieldValue) { + return false; + } + + // If the entered data doesn't have an associated URI, exit early + const loginDomain = Utils.getDomain(data.uri); + if (loginDomain === null) { + return false; + } + + // If no cipher add/update notifications are enabled, we can exit early + const changePasswordNotificationIsEnabled = await this.getEnableChangedPasswordPrompt(); + const newLoginNotificationIsEnabled = await this.getEnableAddedLoginPrompt(); + if (!changePasswordNotificationIsEnabled && !newLoginNotificationIsEnabled) { + return false; + } + + // If there is no account logged in (as opposed to only being locked), exit early + const authStatus = await this.getAuthStatus(); + if (authStatus === AuthenticationStatus.LoggedOut) { + return false; + } + + // If there is no active user, exit early + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getOptionalUserId), + ); + if (activeUserId === null) { + return false; + } + + const normalizedUsername: string = usernameFieldValue ? usernameFieldValue.toLowerCase() : ""; + const currentPasswordFieldHasValue = + typeof currentPasswordFieldValue === "string" && currentPasswordFieldValue.length > 0; + const newPasswordFieldHasValue = + typeof newPasswordFieldValue === "string" && newPasswordFieldValue.length > 0; + const usernameFieldHasValue = + typeof usernameFieldValue === "string" && usernameFieldValue.length > 0; + + // If the current and new password inputs both have values and those values + // match, return early, since no change was made + if ( + currentPasswordFieldHasValue && + newPasswordFieldHasValue && + currentPasswordFieldValue === newPasswordFieldValue + ) { + return false; + } + + /* + * We only show the unlock notification if a new password field was filled, since + * it's very likely to blindly represent an updated cipher value whereas other + * scenarios below require the vault to be unlocked in order to determine + * if an update has been made. + */ + if (authStatus === AuthenticationStatus.Locked) { + if (!newPasswordFieldHasValue) { + return false; + } + // This needs to be the call that includes the full form data + await this.pushChangePasswordToQueue(null, loginDomain, newPasswordFieldValue, tab, true); + + return true; + } + + const ciphersForURL: CipherView[] = await this.cipherService.getAllDecryptedForUrl( + data.uri, + activeUserId, + ); + + // Reducer structured to avoid subsequent array iterations + const ciphersByInputMatchCategory = ciphersForURL.reduce( + (acc, { id, login }) => { + const usernameInputMatchesCipher = + usernameFieldHasValue && login.username?.toLowerCase() === normalizedUsername; + const passwordInputMatchesCipher = + currentPasswordFieldHasValue && login.password === currentPasswordFieldValue; + const newPasswordInputMatchesCipher = + newPasswordFieldHasValue && login.password === newPasswordFieldValue; + + if ( + !newPasswordInputMatchesCipher && + !usernameInputMatchesCipher && + !passwordInputMatchesCipher + ) { + return { ...acc, noFieldMatches: [...acc.noFieldMatches, id] }; + } else if ( + newPasswordInputMatchesCipher && + usernameInputMatchesCipher && + passwordInputMatchesCipher + ) { + // Note: this case should be unreachable due to the early exit comparing + // the password input values against each other, but leaving this bit here + // as a defense against future changes to the pre-match checks. + return { ...acc, allFieldMatches: [...acc.allFieldMatches, id] }; + } else if ( + newPasswordInputMatchesCipher && + !usernameInputMatchesCipher && + !passwordInputMatchesCipher + ) { + return { ...acc, newPasswordOnlyMatches: [...acc.newPasswordOnlyMatches, id] }; + } else if ( + passwordInputMatchesCipher && + !usernameInputMatchesCipher && + !newPasswordInputMatchesCipher + ) { + return { ...acc, passwordOnlyMatches: [...acc.passwordOnlyMatches, id] }; + } else if ( + passwordInputMatchesCipher && + newPasswordInputMatchesCipher && + !usernameInputMatchesCipher + ) { + // Note: this case should be unreachable due to the early exit comparing + // the password input values against each other, but leaving this bit here + // as a defense against future changes to the pre-match checks. + return { ...acc, passwordNewPasswordMatches: [...acc.passwordNewPasswordMatches, id] }; + } else if ( + usernameInputMatchesCipher && + !passwordInputMatchesCipher && + !newPasswordInputMatchesCipher + ) { + return { ...acc, usernameOnlyMatches: [...acc.usernameOnlyMatches, id] }; + } else if ( + usernameInputMatchesCipher && + passwordInputMatchesCipher && + !newPasswordInputMatchesCipher + ) { + return { ...acc, usernamePasswordMatches: [...acc.usernamePasswordMatches, id] }; + } else if ( + usernameInputMatchesCipher && + newPasswordInputMatchesCipher && + !passwordInputMatchesCipher + ) { + return { ...acc, usernameNewPasswordMatches: [...acc.usernameNewPasswordMatches, id] }; + } + + return acc; + }, + { + allFieldMatches: [], + newPasswordOnlyMatches: [], + noFieldMatches: [], + passwordNewPasswordMatches: [], + passwordOnlyMatches: [], + usernameNewPasswordMatches: [], + usernameOnlyMatches: [], + usernamePasswordMatches: [], + }, + ); + + // Handle different field fill combinations and determine the input scenario + const inputScenariosByKey = { + upn: inputScenarios.usernamePasswordNewPassword, + un: inputScenarios.usernameNewPassword, + up: inputScenarios.usernamePassword, + u: inputScenarios.username, + pn: inputScenarios.passwordNewPassword, + n: inputScenarios.newPassword, + p: inputScenarios.password, + } as const; + + type InputScenarioKeys = keyof typeof inputScenariosByKey; + + const key = ((usernameFieldHasValue ? "u" : "") + + (currentPasswordFieldHasValue ? "p" : "") + + (newPasswordFieldHasValue ? "n" : "")) as InputScenarioKeys; + + const inputScenario = key in inputScenariosByKey ? inputScenariosByKey[key] : null; + + if (inputScenario) { + return await this.handleInputMatchScenario({ + ciphersByInputMatchCategory, + ciphersForURL, + loginDomain, + tab, + data, + inputScenario, + changePasswordNotificationIsEnabled, + newLoginNotificationIsEnabled, + }); + } + + return false; + } + /** * Adds a change password message to the notification queue, prompting the user * to update the password for a login that has changed. @@ -668,13 +907,14 @@ export default class NotificationBackground { if ( ciphers.length > 0 && - currentPasswordFieldValue?.length && + (currentPasswordFieldValue?.length || 0) > 0 && // Only use current password for change if no new password present. !newPasswordFieldValue ) { const currentPasswordMatchesAnExistingValue = ciphers.some( (cipher) => - cipher.login?.password?.length && cipher.login.password === currentPasswordFieldValue, + (cipher.login?.password?.length || 0) > 0 && + cipher.login.password === currentPasswordFieldValue, ); // The password entered matched a stored cipher value with @@ -710,6 +950,213 @@ export default class NotificationBackground { return false; } + private async handleInputMatchScenario({ + inputScenario, + ciphersByInputMatchCategory, + ciphersForURL, + loginDomain, + tab, + data, + changePasswordNotificationIsEnabled, + newLoginNotificationIsEnabled, + }: { + ciphersByInputMatchCategory: CiphersByInputMatchCategory; + ciphersForURL: CipherView[]; + loginDomain: string; + tab: chrome.tabs.Tab; + data: ModifyLoginCipherFormData; + inputScenario: InputScenario; + changePasswordNotificationIsEnabled: boolean; + newLoginNotificationIsEnabled: boolean; + }): Promise { + const { + newPasswordOnlyMatches, + noFieldMatches, + passwordOnlyMatches, + usernameNewPasswordMatches, + usernameOnlyMatches, + usernamePasswordMatches, + } = ciphersByInputMatchCategory; + // IMPORTANT! The order of statements matters here; later evaluations + // depend on the assumptions of the early exits in preceding logic + + // If no ciphers match any filled input values + // (Note, this block may uniquely exit early since this match scenario + // involves all ciphers, making it mutually exclusive from any other scenario) + if (noFieldMatches.length === ciphersForURL.length) { + // trigger a new cipher notification in these input scenarios + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernameNewPassword, + inputScenarios.usernamePassword, + inputScenarios.username, + inputScenarios.passwordNewPassword, + ] as InputScenario[] + ).includes(inputScenario) && + newLoginNotificationIsEnabled + ) { + await this.pushAddLoginToQueue( + loginDomain, + { username: data.username, url: data.uri, password: data.newPassword || data.password }, + tab, + ); + + return true; + } + + // Trigger an update cipher notification with all URI ciphers + // in these input scenarios + if ( + ([inputScenarios.password, inputScenarios.newPassword] as InputScenario[]).includes( + inputScenario, + ) && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + ciphersForURL.map((c) => c.id), + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + return false; + } + + // If ciphers match entered username and new password values + if (usernameNewPasswordMatches.length > 0) { + // Early exit in these scenarios as they represent "no change" + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernameNewPassword, + ] as InputScenario[] + ).includes(inputScenario) + ) { + return false; + } + } + + // If ciphers match entered username and password values + if (usernamePasswordMatches.length > 0) { + // and username, password, and new password values were entered + if ( + inputScenario === inputScenarios.usernamePasswordNewPassword && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + usernamePasswordMatches, + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + if (inputScenario === inputScenarios.usernamePassword) { + return false; + } + } + + // If ciphers match entered username value (only) + if (usernameOnlyMatches.length > 0) { + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernameNewPassword, + inputScenarios.usernamePassword, + ] as InputScenario[] + ).includes(inputScenario) && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + usernameOnlyMatches, + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + // Early exit in this scenario as it represents "no change" + if (inputScenario === inputScenarios.username) { + return false; + } + } + + // If ciphers match entered new password value (only) + if (newPasswordOnlyMatches.length > 0) { + // Early exit in these scenarios + if ( + ( + [ + inputScenarios.usernameNewPassword, // unclear user expectation + inputScenarios.password, // likely nothing to change + inputScenarios.newPassword, // nothing to change + ] as InputScenario[] + ).includes(inputScenario) + ) { + return false; + } + + // and username, password, and new password values were entered + if ( + inputScenario === inputScenarios.usernamePasswordNewPassword && + newLoginNotificationIsEnabled + ) { + await this.pushAddLoginToQueue( + loginDomain, + { username: data.username, url: data.uri, password: data.newPassword || data.password }, + tab, + ); + + return true; + } + } + + // If ciphers match entered password value (only) + if (passwordOnlyMatches.length > 0) { + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernamePassword, + inputScenarios.passwordNewPassword, + ] as InputScenario[] + ).includes(inputScenario) && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + passwordOnlyMatches, + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + // Early exit in this scenario as it represents "no change" + if (inputScenario === inputScenarios.password) { + return false; + } + } + + return false; + } + /** * Sends the page details to the notification bar. Will query all * forms with a password field and pass them to the notification bar. @@ -730,6 +1177,7 @@ export default class NotificationBackground { }); } + // @TODO this needs the whole input record, and not just newPassword private async pushChangePasswordToQueue( cipherIds: CipherView["id"][], loginDomain: string, @@ -866,13 +1314,11 @@ export default class NotificationBackground { return; } - const encrypted = await this.cipherService.encrypt(newCipher, activeUserId); - const { cipher } = encrypted; try { - await this.cipherService.createWithServer(encrypted); + const resultCipher = await this.cipherService.createWithServer(newCipher, activeUserId); await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", { itemName: newCipher?.name && String(newCipher?.name), - cipherId: cipher?.id && String(cipher?.id), + cipherId: resultCipher?.id && String(resultCipher?.id), }); await BrowserApi.tabSendMessage(tab, { command: "addedCipher" }); } catch (error) { @@ -910,7 +1356,6 @@ export default class NotificationBackground { await BrowserApi.tabSendMessage(tab, { command: "editedCipher" }); return; } - const cipher = await this.cipherService.encrypt(cipherView, userId); try { if (!cipherView.edit) { @@ -939,7 +1384,7 @@ export default class NotificationBackground { return; } - await this.cipherService.updateWithServer(cipher); + await this.cipherService.updateWithServer(cipherView, userId); await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", { itemName: cipherView?.name && String(cipherView?.name), diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts index c596a1ba774..28e03b64621 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts @@ -1,4 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; @@ -32,6 +33,7 @@ describe("OverlayNotificationsBackground", () => { jest.useFakeTimers(); logService = mock(); notificationBackground = mock(); + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(false); getEnableChangedPasswordPromptSpy = jest .spyOn(notificationBackground, "getEnableChangedPasswordPrompt") .mockResolvedValue(true); @@ -323,6 +325,7 @@ describe("OverlayNotificationsBackground", () => { const pageDetails = mock({ fields: [mock()] }); let notificationChangedPasswordSpy: jest.SpyInstance; let notificationAddLoginSpy: jest.SpyInstance; + let cipherNotificationSpy: jest.SpyInstance; beforeEach(async () => { sender = mock({ @@ -334,6 +337,7 @@ describe("OverlayNotificationsBackground", () => { "triggerChangedPasswordNotification", ); notificationAddLoginSpy = jest.spyOn(notificationBackground, "triggerAddLoginNotification"); + cipherNotificationSpy = jest.spyOn(notificationBackground, "triggerCipherNotification"); sendMockExtensionMessage( { command: "collectPageDetailsResponse", details: pageDetails }, @@ -456,6 +460,7 @@ describe("OverlayNotificationsBackground", () => { const pageDetails = mock({ fields: [mock()] }); beforeEach(async () => { + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(false); sendMockExtensionMessage( { command: "collectPageDetailsResponse", details: pageDetails }, sender, @@ -519,6 +524,44 @@ describe("OverlayNotificationsBackground", () => { expect(notificationAddLoginSpy).toHaveBeenCalled(); }); + it("with `useUndeterminedCipherScenarioTriggeringLogic` on, waits for the tab's navigation to complete using the web navigation API before initializing the notification", async () => { + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true); + chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => { + callback( + mock({ + status: "loading", + url: sender.url, + }), + ); + }); + triggerWebRequestOnCompletedEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + requestId, + }), + ); + await flushPromises(); + + chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => { + callback( + mock({ + status: "complete", + url: sender.url, + }), + ); + }); + triggerWebNavigationOnCompletedEvent( + mock({ + tabId: sender.tab.id, + url: sender.url, + }), + ); + await flushPromises(); + + expect(cipherNotificationSpy).toHaveBeenCalled(); + }); + it("initializes the notification immediately when the tab's navigation is complete", async () => { sendMockExtensionMessage( { @@ -552,6 +595,40 @@ describe("OverlayNotificationsBackground", () => { expect(notificationAddLoginSpy).toHaveBeenCalled(); }); + it("with `useUndeterminedCipherScenarioTriggeringLogic` on, initializes the notification immediately when the tab's navigation is complete", async () => { + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true); + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }, + sender, + ); + await flushPromises(); + chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => { + callback( + mock({ + status: "complete", + url: sender.url, + }), + ); + }); + + triggerWebRequestOnCompletedEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + requestId, + }), + ); + await flushPromises(); + + expect(cipherNotificationSpy).toHaveBeenCalled(); + }); + it("triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => { sender.tab = mock({ id: 4 }); sendMockExtensionMessage( @@ -601,6 +678,57 @@ describe("OverlayNotificationsBackground", () => { expect(notificationChangedPasswordSpy).toHaveBeenCalled(); }); + + it("with `useUndeterminedCipherScenarioTriggeringLogic` on, triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => { + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true); + sender.tab = mock({ id: 4 }); + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails }, + sender, + ); + await flushPromises(); + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "", + password: "password", + newPassword: "newPassword", + }, + sender, + ); + await flushPromises(); + chrome.tabs.get = jest.fn().mockImplementation((tabId, callback) => { + callback( + mock({ + status: "complete", + url: sender.url, + }), + ); + }); + + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + requestId, + }), + ); + await flushPromises(); + + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: "https://example.com/redirect", + tabId: sender.tab.id, + method: "GET", + requestId, + }), + ); + await flushPromises(); + + expect(cipherNotificationSpy).toHaveBeenCalled(); + }); }); }); diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index 4f55e68fb41..dea6dc5c44c 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -1,10 +1,9 @@ -import { Subject, switchMap, timer } from "rxjs"; +import { firstValueFrom, Subject, switchMap, timer } from "rxjs"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { BrowserApi } from "../../platform/browser/browser-api"; -import { NotificationType, NotificationTypes } from "../notification/abstractions/notification-bar"; import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils"; import { @@ -14,6 +13,8 @@ import { OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface, OverlayNotificationsExtensionMessage, OverlayNotificationsExtensionMessageHandlers, + NotificationScenarios, + NotificationScenario, WebsiteOriginsWithFields, } from "./abstractions/overlay-notifications.background"; import NotificationBackground from "./notification.background"; @@ -32,7 +33,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg collectPageDetailsResponse: ({ message, sender }) => this.handleCollectPageDetailsResponse(message, sender), }; - constructor( private logService: LogService, private notificationBackground: NotificationBackground, @@ -281,7 +281,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const shouldAttemptAddNotification = this.shouldAttemptNotification( modifyLoginData, - NotificationTypes.Add, + NotificationScenarios.Add, ); if (shouldAttemptAddNotification) { @@ -290,7 +290,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const shouldAttemptChangeNotification = this.shouldAttemptNotification( modifyLoginData, - NotificationTypes.Change, + NotificationScenarios.Change, ); if (shouldAttemptChangeNotification) { @@ -445,29 +445,45 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg requestId: chrome.webRequest.WebRequestDetails["requestId"], modifyLoginData: ModifyLoginCipherFormData, tab: chrome.tabs.Tab, - config: { skippable: NotificationType[] } = { skippable: [] }, + config: { skippable: NotificationScenario[] } = { skippable: [] }, ) => { - const notificationCandidates = [ - { - type: NotificationTypes.Change, - trigger: this.notificationBackground.triggerChangedPasswordNotification, - }, - { - type: NotificationTypes.Add, - trigger: this.notificationBackground.triggerAddLoginNotification, - }, - { - type: NotificationTypes.AtRiskPassword, - trigger: this.notificationBackground.triggerAtRiskPasswordNotification, - }, - ].filter( + const useUndeterminedCipherScenarioTriggeringLogic = await firstValueFrom( + this.notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$, + ); + + const notificationCandidates = useUndeterminedCipherScenarioTriggeringLogic + ? [ + { + type: NotificationScenarios.Cipher, + trigger: this.notificationBackground.triggerCipherNotification, + }, + { + type: NotificationScenarios.AtRiskPassword, + trigger: this.notificationBackground.triggerAtRiskPasswordNotification, + }, + ] + : [ + { + type: NotificationScenarios.Change, + trigger: this.notificationBackground.triggerChangedPasswordNotification, + }, + { + type: NotificationScenarios.Add, + trigger: this.notificationBackground.triggerAddLoginNotification, + }, + { + type: NotificationScenarios.AtRiskPassword, + trigger: this.notificationBackground.triggerAtRiskPasswordNotification, + }, + ]; + const filteredNotificationCandidates = notificationCandidates.filter( (candidate) => this.shouldAttemptNotification(modifyLoginData, candidate.type) || config.skippable.includes(candidate.type), ); const results: string[] = []; - for (const { trigger, type } of notificationCandidates) { + for (const { trigger, type } of filteredNotificationCandidates) { const success = await trigger.bind(this.notificationBackground)(modifyLoginData, tab); if (success) { results.push(`Success: ${type}`); @@ -489,8 +505,16 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg */ private shouldAttemptNotification = ( modifyLoginData: ModifyLoginCipherFormData, - notificationType: NotificationType, + notificationType: NotificationScenario, ): boolean => { + if (notificationType === NotificationScenarios.Cipher) { + // The logic after this block pre-qualifies some cipher add/update scenarios + // prematurely (where matching against vault contents is required) and should be + // skipped for this case (these same checks are performed early in the + // notification triggering logic). + return true; + } + // Intentionally not stripping whitespace characters here as they // represent user entry. const usernameFieldHasValue = !!(modifyLoginData?.username || "").length; @@ -504,15 +528,15 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg // `Add` case included because all forms with cached usernames (from previous // visits) will appear to be "password only" and otherwise trigger the new login // save notification. - case NotificationTypes.Add: + case NotificationScenarios.Add: // Can be values for nonstored login or account creation return usernameFieldHasValue && (passwordFieldHasValue || newPasswordFieldHasValue); - case NotificationTypes.Change: + case NotificationScenarios.Change: // Can be login with nonstored login changes or account password update return canBeUserLogin || canBePasswordUpdate; - case NotificationTypes.AtRiskPassword: + case NotificationScenarios.AtRiskPassword: return !newPasswordFieldHasValue; - case NotificationTypes.Unlock: + case NotificationScenarios.Unlock: // Unlock notifications are handled separately and do not require form data return false; default: diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-icon.ts b/apps/browser/src/autofill/content/components/cipher/cipher-icon.ts index 66b0d31bddf..5f9b9eb8370 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-icon.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-icon.ts @@ -27,4 +27,5 @@ export function CipherIcon({ color, size, theme, uri }: CipherIconProps) { const cipherIconStyle = ({ width }: { width: string }) => css` width: ${width}; height: fit-content; + max-height: 24px; /* fallback for Safari */ `; diff --git a/apps/browser/src/autofill/content/components/rows/cipher-item-row.ts b/apps/browser/src/autofill/content/components/rows/cipher-item-row.ts index 0600fc9ac4b..9fcf5c95656 100644 --- a/apps/browser/src/autofill/content/components/rows/cipher-item-row.ts +++ b/apps/browser/src/autofill/content/components/rows/cipher-item-row.ts @@ -51,6 +51,7 @@ const cipherItemRowStyles = ({ theme }: { theme: Theme }) => css` background-color: ${themes[theme].background.DEFAULT}; padding: ${spacing["2"]} ${spacing["3"]}; min-height: min-content; + min-height: 36px; /* fallback for Firefox, which doesn't support min-height: min-content on flex items */ max-height: 52px; overflow-x: hidden; white-space: nowrap; diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts index 1cd614a9516..d55e0827352 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts @@ -1,12 +1,10 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { WebauthnUtils } from "../utils/webauthn-utils"; import { MessageTypes } from "./messaging/message"; import { Messenger } from "./messaging/messenger"; (function (globalContext) { - if (globalContext.document.currentScript) { + if (globalContext.document.currentScript?.parentNode) { globalContext.document.currentScript.parentNode.removeChild( globalContext.document.currentScript, ); @@ -86,7 +84,7 @@ import { Messenger } from "./messaging/messenger"; */ async function createWebAuthnCredential( options?: CredentialCreationOptions, - ): Promise { + ): Promise { if (!isWebauthnCall(options)) { return await browserCredentials.create(options); } @@ -106,13 +104,18 @@ import { Messenger } from "./messaging/messenger"; options?.signal, ); - if (response.type !== MessageTypes.CredentialCreationResponse) { + if (response.type !== MessageTypes.CredentialCreationResponse || !response.result) { throw new Error("Something went wrong."); } return WebauthnUtils.mapCredentialRegistrationResult(response.result); } catch (error) { - if (error && error.fallbackRequested && fallbackSupported) { + if ( + fallbackSupported && + error instanceof Object && + "fallbackRequested" in error && + error.fallbackRequested + ) { await waitForFocus(); return await browserCredentials.create(options); } @@ -127,7 +130,9 @@ import { Messenger } from "./messaging/messenger"; * @param options Options for creating new credentials. * @returns Promise that resolves to the new credential object. */ - async function getWebAuthnCredential(options?: CredentialRequestOptions): Promise { + async function getWebAuthnCredential( + options?: CredentialRequestOptions, + ): Promise { if (!isWebauthnCall(options)) { return await browserCredentials.get(options); } @@ -153,7 +158,7 @@ import { Messenger } from "./messaging/messenger"; internalAbortController.signal, ); internalAbortController.signal.removeEventListener("abort", abortListener); - if (response.type !== MessageTypes.CredentialGetResponse) { + if (response.type !== MessageTypes.CredentialGetResponse || !response.result) { throw new Error("Something went wrong."); } @@ -176,7 +181,7 @@ import { Messenger } from "./messaging/messenger"; abortSignal.removeEventListener("abort", abortListener); internalAbortControllers.forEach((controller) => controller.abort()); - return response; + return response ?? null; } try { @@ -188,13 +193,18 @@ import { Messenger } from "./messaging/messenger"; options?.signal, ); - if (response.type !== MessageTypes.CredentialGetResponse) { + if (response.type !== MessageTypes.CredentialGetResponse || !response.result) { throw new Error("Something went wrong."); } return WebauthnUtils.mapCredentialAssertResult(response.result); } catch (error) { - if (error && error.fallbackRequested && fallbackSupported) { + if ( + fallbackSupported && + error instanceof Object && + "fallbackRequested" in error && + error.fallbackRequested + ) { await waitForFocus(); return await browserCredentials.get(options); } @@ -203,8 +213,10 @@ import { Messenger } from "./messaging/messenger"; } } - function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) { - return options && "publicKey" in options; + function isWebauthnCall( + options?: CredentialCreationOptions | CredentialRequestOptions, + ): options is CredentialCreationOptions | CredentialRequestOptions { + return options != null && "publicKey" in options; } /** @@ -217,7 +229,7 @@ import { Messenger } from "./messaging/messenger"; */ async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) { try { - if (globalContext.top.document.hasFocus()) { + if (globalContext.top?.document.hasFocus()) { return; } } catch { @@ -225,9 +237,14 @@ import { Messenger } from "./messaging/messenger"; return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait)); } + if (!globalContext.top) { + return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait)); + } + + const topWindow = globalContext.top; const focusPromise = new Promise((resolve) => { focusListenerHandler = () => resolve(); - globalContext.top.addEventListener("focus", focusListenerHandler); + topWindow.addEventListener("focus", focusListenerHandler); }); const timeoutPromise = new Promise((_, reject) => { @@ -248,7 +265,7 @@ import { Messenger } from "./messaging/messenger"; } function clearWaitForFocus() { - globalContext.top.removeEventListener("focus", focusListenerHandler); + globalContext.top?.removeEventListener("focus", focusListenerHandler); if (waitForFocusTimeout) { globalContext.clearTimeout(waitForFocusTimeout); } diff --git a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts index 257f7e9efd5..78bb9aa8f33 100644 --- a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts +++ b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Message, MessageTypes } from "./message"; const SENDER = "bitwarden-webauthn"; @@ -25,7 +23,9 @@ type Handler = ( * handling aborts and exceptions across separate execution contexts. */ export class Messenger { - private messageEventListener: (event: MessageEvent) => void | null = null; + private messageEventListener: + | ((event: MessageEvent) => void | Promise) + | null = null; private onDestroy = new EventTarget(); /** diff --git a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts index b23c3c17abb..923db8d4b5c 100644 --- a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts +++ b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts @@ -8,9 +8,13 @@ import { } from "../../../autofill/content/components/common-types"; const NotificationTypes = { + /** represents scenarios handling saving new ciphers after form submit */ Add: "add", + /** represents scenarios handling saving updated ciphers after form submit */ Change: "change", + /** represents scenarios where user has interacted with an unlock action prompt or action otherwise requiring unlock as a prerequisite */ Unlock: "unlock", + /** represents scenarios where the user has security tasks after updating ciphers */ AtRiskPassword: "at-risk-password", } as const; diff --git a/apps/browser/src/autofill/popup/fido2/fido2.component.ts b/apps/browser/src/autofill/popup/fido2/fido2.component.ts index c1982d27d24..5720419f909 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2.component.ts @@ -444,10 +444,9 @@ export class Fido2Component implements OnInit, OnDestroy { ); this.buildCipher(name, username); - const encrypted = await this.cipherService.encrypt(this.cipher, activeUserId); try { - await this.cipherService.createWithServer(encrypted); - this.cipher.id = encrypted.cipher.id; + const result = await this.cipherService.createWithServer(this.cipher, activeUserId); + this.cipher.id = result.id; } catch (e) { this.logService.error(e); } diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html index 75be3bcc1a0..30170820a27 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.html +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.html @@ -8,7 +8,9 @@

{{ - accountSwitcherEnabled ? ("excludedDomainsDescAlt" | i18n) : ("excludedDomainsDesc" | i18n) + (accountSwitcherEnabled$ | async) + ? ("excludedDomainsDescAlt" | i18n) + : ("excludedDomainsDesc" | i18n) }}

diff --git a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts index e67c826cac6..6714f749d2d 100644 --- a/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/autofill/popup/settings/excluded-domains.component.ts @@ -15,7 +15,7 @@ import { FormArray, } from "@angular/forms"; import { RouterModule } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { Observable, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; @@ -35,7 +35,7 @@ import { TypographyModule, } from "@bitwarden/components"; -import { enableAccountSwitching } from "../../../platform/flags"; +import { AccountSwitcherService } from "../../../auth/popup/account-switching/services/account-switcher.service"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; @@ -74,7 +74,8 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { @ViewChildren("uriInput") uriInputElements: QueryList> = new QueryList(); - accountSwitcherEnabled = false; + readonly accountSwitcherEnabled$: Observable = + this.accountSwitcherService.accountSwitchingEnabled$(); dataIsPristine = true; isLoading = false; excludedDomainsState: string[] = []; @@ -95,9 +96,8 @@ export class ExcludedDomainsComponent implements AfterViewInit, OnDestroy { private toastService: ToastService, private formBuilder: FormBuilder, private popupRouterCacheService: PopupRouterCacheService, - ) { - this.accountSwitcherEnabled = enableAccountSwitching(); - } + private accountSwitcherService: AccountSwitcherService, + ) {} get domainForms() { return this.domainListForm.get("domains") as FormArray; diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 66a692dbe20..58f3ad11166 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -158,7 +158,7 @@ describe("CollectAutofillContentService", () => { type: "text", value: "", checked: false, - autoCompleteType: "", + autoCompleteType: null, disabled: false, readonly: false, selectInfo: null, @@ -346,7 +346,7 @@ describe("CollectAutofillContentService", () => { type: "text", value: "", checked: false, - autoCompleteType: "", + autoCompleteType: null, disabled: false, readonly: false, selectInfo: null, @@ -379,7 +379,7 @@ describe("CollectAutofillContentService", () => { type: "password", value: "", checked: false, - autoCompleteType: "", + autoCompleteType: null, disabled: false, readonly: false, selectInfo: null, @@ -588,7 +588,7 @@ describe("CollectAutofillContentService", () => { "aria-disabled": false, "aria-haspopup": false, "aria-hidden": false, - autoCompleteType: "", + autoCompleteType: null, checked: false, "data-stripe": null, disabled: false, @@ -621,7 +621,7 @@ describe("CollectAutofillContentService", () => { "aria-disabled": false, "aria-haspopup": false, "aria-hidden": false, - autoCompleteType: "", + autoCompleteType: null, checked: false, "data-stripe": null, disabled: false, @@ -2507,9 +2507,7 @@ describe("CollectAutofillContentService", () => { "class", "tabindex", "title", - "value", "rel", - "tagname", "checked", "disabled", "readonly", diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 117c7c5e2a4..1d464e1313f 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AUTOFILL_ATTRIBUTES } from "@bitwarden/common/autofill/constants"; + import AutofillField from "../models/autofill-field"; import AutofillForm from "../models/autofill-form"; import AutofillPageDetails from "../models/autofill-page-details"; @@ -242,10 +244,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this._autofillFormElements.set(formElement, { opid: formElement.opid, htmlAction: this.getFormActionAttribute(formElement), - htmlName: this.getPropertyOrAttribute(formElement, "name"), - htmlClass: this.getPropertyOrAttribute(formElement, "class"), - htmlID: this.getPropertyOrAttribute(formElement, "id"), - htmlMethod: this.getPropertyOrAttribute(formElement, "method"), + htmlName: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.NAME), + htmlClass: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.CLASS), + htmlID: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.ID), + htmlMethod: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.METHOD), }); } @@ -260,7 +262,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ * @private */ private getFormActionAttribute(element: ElementWithOpId): string { - return new URL(this.getPropertyOrAttribute(element, "action"), globalThis.location.href).href; + return new URL( + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ACTION), + globalThis.location.href, + ).href; } /** @@ -335,7 +340,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ return priorityFormFields; } - const fieldType = this.getPropertyOrAttribute(element, "type")?.toLowerCase(); + const fieldType = this.getPropertyOrAttribute( + element, + AUTOFILL_ATTRIBUTES.TYPE, + )?.toLowerCase(); if (unimportantFieldTypesSet.has(fieldType)) { unimportantFormFields.push(element); continue; @@ -384,11 +392,11 @@ export class CollectAutofillContentService implements CollectAutofillContentServ elementNumber: index, maxLength: this.getAutofillFieldMaxLength(element), viewable: await this.domElementVisibilityService.isElementViewable(element), - htmlID: this.getPropertyOrAttribute(element, "id"), - htmlName: this.getPropertyOrAttribute(element, "name"), - htmlClass: this.getPropertyOrAttribute(element, "class"), - tabindex: this.getPropertyOrAttribute(element, "tabindex"), - title: this.getPropertyOrAttribute(element, "title"), + htmlID: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ID), + htmlName: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.NAME), + htmlClass: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.CLASS), + tabindex: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.TABINDEX), + title: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.TITLE), tagName: this.getAttributeLowerCase(element, "tagName"), dataSetValues: this.getDataSetValues(element), }; @@ -404,16 +412,16 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } let autofillFieldLabels = {}; - const elementType = this.getAttributeLowerCase(element, "type"); + const elementType = this.getAttributeLowerCase(element, AUTOFILL_ATTRIBUTES.TYPE); if (elementType !== "hidden") { autofillFieldLabels = { "label-tag": this.createAutofillFieldLabelTag(element as FillableFormFieldElement), - "label-data": this.getPropertyOrAttribute(element, "data-label"), - "label-aria": this.getPropertyOrAttribute(element, "aria-label"), + "label-data": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.DATA_LABEL), + "label-aria": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ARIA_LABEL), "label-top": this.createAutofillFieldTopLabel(element), "label-right": this.createAutofillFieldRightLabel(element), "label-left": this.createAutofillFieldLeftLabel(element), - placeholder: this.getPropertyOrAttribute(element, "placeholder"), + placeholder: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.PLACEHOLDER), }; } @@ -421,21 +429,21 @@ export class CollectAutofillContentService implements CollectAutofillContentServ const autofillField = { ...autofillFieldBase, ...autofillFieldLabels, - rel: this.getPropertyOrAttribute(element, "rel"), + rel: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.REL), type: elementType, value: this.getElementValue(element), - checked: this.getAttributeBoolean(element, "checked"), + checked: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.CHECKED), autoCompleteType: this.getAutoCompleteAttribute(element), - disabled: this.getAttributeBoolean(element, "disabled"), - readonly: this.getAttributeBoolean(element, "readonly"), + disabled: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.DISABLED), + readonly: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.READONLY), selectInfo: elementIsSelectElement(element) ? this.getSelectElementOptions(element as HTMLSelectElement) : null, form: fieldFormElement ? this.getPropertyOrAttribute(fieldFormElement, "opid") : null, - "aria-hidden": this.getAttributeBoolean(element, "aria-hidden", true), - "aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true), - "aria-haspopup": this.getAttributeBoolean(element, "aria-haspopup", true), - "data-stripe": this.getPropertyOrAttribute(element, "data-stripe"), + "aria-hidden": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_HIDDEN, true), + "aria-disabled": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_DISABLED, true), + "aria-haspopup": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_HASPOPUP, true), + "data-stripe": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.DATA_STRIPE), }; this.cacheAutofillFieldElement(index, element, autofillField); @@ -467,9 +475,9 @@ export class CollectAutofillContentService implements CollectAutofillContentServ */ private getAutoCompleteAttribute(element: ElementWithOpId): string { return ( - this.getPropertyOrAttribute(element, "x-autocompletetype") || - this.getPropertyOrAttribute(element, "autocompletetype") || - this.getPropertyOrAttribute(element, "autocomplete") + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.AUTOCOMPLETE) || + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.X_AUTOCOMPLETE_TYPE) || + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.AUTOCOMPLETE_TYPE) ); } @@ -957,6 +965,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.mutationObserver = new MutationObserver(this.handleMutationObserverMutation); this.mutationObserver.observe(document.documentElement, { attributes: true, + /** Mutations to node attributes NOT on this list will not be observed! */ + attributeFilter: Object.values(AUTOFILL_ATTRIBUTES), childList: true, subtree: true, }); @@ -1321,6 +1331,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ action: () => (dataTarget.htmlAction = this.getFormActionAttribute(element)), name: () => updateAttribute("htmlName"), id: () => updateAttribute("htmlID"), + class: () => updateAttribute("htmlClass"), method: () => updateAttribute("htmlMethod"), }; @@ -1350,29 +1361,49 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey }); }; const updateActions: Record = { - maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)), - id: () => updateAttribute("htmlID"), - name: () => updateAttribute("htmlName"), - class: () => updateAttribute("htmlClass"), - tabindex: () => updateAttribute("tabindex"), - title: () => updateAttribute("tabindex"), - rel: () => updateAttribute("rel"), - tagname: () => (dataTarget.tagName = this.getAttributeLowerCase(element, "tagName")), - type: () => (dataTarget.type = this.getAttributeLowerCase(element, "type")), - value: () => (dataTarget.value = this.getElementValue(element)), - checked: () => (dataTarget.checked = this.getAttributeBoolean(element, "checked")), - disabled: () => (dataTarget.disabled = this.getAttributeBoolean(element, "disabled")), - readonly: () => (dataTarget.readonly = this.getAttributeBoolean(element, "readonly")), - autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), - "data-label": () => updateAttribute("label-data"), + "aria-describedby": () => updateAttribute(AUTOFILL_ATTRIBUTES.ARIA_DESCRIBEDBY), "aria-label": () => updateAttribute("label-aria"), + "aria-labelledby": () => updateAttribute(AUTOFILL_ATTRIBUTES.ARIA_LABELLEDBY), "aria-hidden": () => - (dataTarget["aria-hidden"] = this.getAttributeBoolean(element, "aria-hidden", true)), + (dataTarget["aria-hidden"] = this.getAttributeBoolean( + element, + AUTOFILL_ATTRIBUTES.ARIA_HIDDEN, + true, + )), "aria-disabled": () => - (dataTarget["aria-disabled"] = this.getAttributeBoolean(element, "aria-disabled", true)), + (dataTarget["aria-disabled"] = this.getAttributeBoolean( + element, + AUTOFILL_ATTRIBUTES.ARIA_DISABLED, + true, + )), "aria-haspopup": () => - (dataTarget["aria-haspopup"] = this.getAttributeBoolean(element, "aria-haspopup", true)), - "data-stripe": () => updateAttribute("data-stripe"), + (dataTarget["aria-haspopup"] = this.getAttributeBoolean( + element, + AUTOFILL_ATTRIBUTES.ARIA_HASPOPUP, + true, + )), + autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + autocompletetype: () => + (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + "x-autocompletetype": () => + (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + class: () => updateAttribute("htmlClass"), + checked: () => + (dataTarget.checked = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.CHECKED)), + "data-label": () => updateAttribute("label-data"), + "data-stripe": () => updateAttribute(AUTOFILL_ATTRIBUTES.DATA_STRIPE), + disabled: () => + (dataTarget.disabled = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.DISABLED)), + id: () => updateAttribute("htmlID"), + maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)), + name: () => updateAttribute("htmlName"), + placeholder: () => updateAttribute(AUTOFILL_ATTRIBUTES.PLACEHOLDER), + readonly: () => + (dataTarget.readonly = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.READONLY)), + rel: () => updateAttribute(AUTOFILL_ATTRIBUTES.REL), + tabindex: () => updateAttribute(AUTOFILL_ATTRIBUTES.TABINDEX), + title: () => updateAttribute(AUTOFILL_ATTRIBUTES.TITLE), + type: () => (dataTarget.type = this.getAttributeLowerCase(element, AUTOFILL_ATTRIBUTES.TYPE)), }; if (!updateActions[attributeName]) { diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 58a7eb99ec6..eb6d26357eb 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -139,8 +139,6 @@ import { IpcService, IpcSessionRepository } from "@bitwarden/common/platform/ipc import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency creation import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; -import { Lazy } from "@bitwarden/common/platform/misc/lazy"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; // eslint-disable-next-line no-restricted-imports -- Needed for service creation import { @@ -194,6 +192,7 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service" import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; @@ -211,6 +210,7 @@ import { CipherAuthorizationService, DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; @@ -367,6 +367,7 @@ export default class MainBackground { apiService: ApiServiceAbstraction; hibpApiService: HibpApiService; environmentService: BrowserEnvironmentService; + cipherSdkService: CipherSdkService; cipherService: CipherServiceAbstraction; folderService: InternalFolderServiceAbstraction; userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction; @@ -562,36 +563,18 @@ export default class MainBackground { this.memoryStorageService = this.memoryStorageForStateProviders; } + this.encryptService = new EncryptServiceImplementation( + this.cryptoFunctionService, + this.logService, + true, + ); + if (BrowserApi.isManifestVersion(3)) { - // Creates a session key for mv3 storage of large memory items - const sessionKey = new Lazy(async () => { - // Key already in session storage - const sessionStorage = new BrowserMemoryStorageService(); - const existingKey = await sessionStorage.get("session-key"); - if (existingKey) { - if (sessionStorage.valuesRequireDeserialization) { - return SymmetricCryptoKey.fromJSON(existingKey); - } - return existingKey; - } - - // New key - const { derivedKey } = await this.keyGenerationService.createKeyWithPurpose( - 128, - "ephemeral", - "bitwarden-ephemeral", - ); - await sessionStorage.save("session-key", derivedKey.toJSON()); - return derivedKey; - }); - this.largeObjectMemoryStorageForStateProviders = new LocalBackedSessionStorageService( - sessionKey, + new BrowserMemoryStorageService(), this.storageService, - // For local backed session storage, we expect that the encrypted data on disk will persist longer than the encryption key in memory - // and failures to decrypt because of that are completely expected. For this reason, we pass in `false` to the `EncryptServiceImplementation` - // so that MAC failures are not logged. - new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), + this.keyGenerationService, + this.encryptService, this.platformUtilsService, this.logService, ); @@ -626,12 +609,6 @@ export default class MainBackground { storageServiceProvider, ); - this.encryptService = new EncryptServiceImplementation( - this.cryptoFunctionService, - this.logService, - true, - ); - this.singleUserStateProvider = new DefaultSingleUserStateProvider( storageServiceProvider, stateEventRegistrarService, @@ -973,6 +950,8 @@ export default class MainBackground { this.logService, ); + this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService); + this.cipherService = new CipherService( this.keyService, this.domainSettingsService, @@ -988,6 +967,7 @@ export default class MainBackground { this.logService, this.cipherEncryptionService, this.messagingService, + this.cipherSdkService, ); this.folderService = new FolderService( this.keyService, @@ -1025,6 +1005,8 @@ export default class MainBackground { this.keyGenerationService, this.sendStateProvider, this.encryptService, + this.cryptoFunctionService, + this.configService, ); this.sendApiService = new SendApiService( this.apiService, diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index eba6b01fe90..7483e71f87f 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -291,15 +291,21 @@ export default class RuntimeBackground { } break; case "openPopup": - await this.openPopup(); + await this.executeMessageActionOrOpenPopup(msg, this.openPopup.bind(this)); break; case VaultMessages.OpenAtRiskPasswords: { - await this.main.openAtRisksPasswordsPage(); + await this.executeMessageActionOrOpenPopup( + msg, + this.main.openAtRisksPasswordsPage.bind(this), + ); this.announcePopupOpen(); break; } case VaultMessages.OpenBrowserExtensionToUrl: { - await this.main.openTheExtensionToPage(msg.url); + await this.executeMessageActionOrOpenPopup( + msg, + this.main.openTheExtensionToPage.bind(this, msg.url), + ); this.announcePopupOpen(); break; } @@ -374,40 +380,55 @@ export default class RuntimeBackground { * @param message * @returns true if message fails validation */ - private async shouldRejectManyOriginMessage(message: { - webExtSender: chrome.runtime.MessageSender; - }): Promise { + private async executeMessageActionOrOpenPopup( + message: { + webExtSender: chrome.runtime.MessageSender; + }, + messageAction: () => Promise, + ): Promise { + const hasAccounts = await firstValueFrom( + this.accountService.accounts$.pipe(map((a) => Object.keys(a).length > 0)), + ); + + // When there are no accounts associated with the extension, only allow opening the popup + if (!hasAccounts) { + await this.openPopup(); + return; + } + const isValidVaultReferrer = await this.isValidVaultReferrer( Utils.getHostname(message?.webExtSender?.origin), ); - if (isValidVaultReferrer) { - return false; + // When the referrer is not a known vault and the message is external, reject the message + if (!isValidVaultReferrer && isExternalMessage(message)) { + return; } - return isExternalMessage(message); + await messageAction(); } /** - * Validates a message's referrer matches the configured web vault hostname. + * Validates that a referrer hostname matches any of the available regions' and current environment web vault URLs. * - * @param referrer - hostname from message source - * @returns true if referrer matches web vault + * @param referrer - hostname from message source (should not include protocol or path) + * @returns true if referrer matches any known vault hostname, false otherwise */ private async isValidVaultReferrer(referrer: string | null | undefined): Promise { if (!referrer) { return false; } - const env = await firstValueFrom(this.environmentService.environment$); - const vaultUrl = env.getWebVaultUrl(); - const vaultHostname = Utils.getHostname(vaultUrl); + const environment = await firstValueFrom(this.environmentService.environment$); - if (!vaultHostname) { - return false; - } + const regions = this.environmentService.availableRegions(); + const regionVaultUrls = regions.map((r) => r.urls.webVault ?? r.urls.base); + const environmentWebVaultUrl = environment.getWebVaultUrl(); + const messageIsFromKnownVault = [...regionVaultUrls, environmentWebVaultUrl].some( + (webVaultUrl) => Utils.getHostname(webVaultUrl) === referrer, + ); - return vaultHostname === referrer; + return messageIsFromKnownVault; } private async autofillPage(tabToAutoFill: chrome.tabs.Tab) { diff --git a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts index 4cd155c8ae3..88068987dd7 100644 --- a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts +++ b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts @@ -1,6 +1,8 @@ export type PhishingResource = { name?: string; remoteUrl: string; + /** Fallback URL to use if remoteUrl fails (e.g., due to SSL interception/cert issues) */ + fallbackUrl: string; checksumUrl: string; todayUrl: string; /** Matcher used to decide whether a given URL matches an entry from this resource */ @@ -19,6 +21,8 @@ export const PHISHING_RESOURCES: Record - new Promise((resolve) => jest.requireActual("timers").setImmediate(resolve)); - -// [FIXME] Move mocking and compression helpers to a shared test utils library -// to separate from phishing data service tests. -export const setupPhishingMocks = (mockedResult: string | ArrayBuffer = "mocked-data") => { - // Store original globals - const originals = { - Response: global.Response, - CompressionStream: global.CompressionStream, - DecompressionStream: global.DecompressionStream, - Blob: global.Blob, - atob: global.atob, - btoa: global.btoa, - }; - - // Mock missing or browser-only globals - global.atob = (str) => Buffer.from(str, "base64").toString("binary"); - global.btoa = (str) => Buffer.from(str, "binary").toString("base64"); - - (global as any).CompressionStream = class {}; - (global as any).DecompressionStream = class {}; - - global.Blob = class { - constructor(public parts: any[]) {} - stream() { - return { pipeThrough: () => ({}) }; - } - } as any; - - global.Response = class { - body = { pipeThrough: () => ({}) }; - // Return string for decompression - text() { - return Promise.resolve(typeof mockedResult === "string" ? mockedResult : ""); - } - // Return ArrayBuffer for compression - arrayBuffer() { - if (typeof mockedResult === "string") { - const bytes = new TextEncoder().encode(mockedResult); - return Promise.resolve(bytes.buffer); - } - - return Promise.resolve(mockedResult); - } - } as any; - - // Cleanup function - return () => { - Object.assign(global, originals); - }; -}; +import { PHISHING_DOMAINS_META_KEY, PhishingDataService } from "./phishing-data.service"; +import type { PhishingIndexedDbService } from "./phishing-indexeddb.service"; describe("PhishingDataService", () => { let service: PhishingDataService; @@ -76,33 +19,31 @@ describe("PhishingDataService", () => { let taskSchedulerService: TaskSchedulerService; let logService: MockProxy; let platformUtilsService: MockProxy; + let mockIndexedDbService: MockProxy; const fakeGlobalStateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider(); - - const setMockMeta = (state: PhishingDataMeta) => { - fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_META_KEY).stateSubject.next(state); - return state; - }; - const setMockBlob = (state: PhishingDataBlob) => { - fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_BLOB_KEY).stateSubject.next(state); - return state; - }; - let fetchChecksumSpy: jest.SpyInstance; - let fetchAndCompressSpy: jest.SpyInstance; - - const mockMeta: PhishingDataMeta = { - checksum: "abc", - timestamp: Date.now(), - applicationVersion: "1.0.0", - }; - const mockBlob = "http://phish.com\nhttps://badguy.net"; - const mockCompressedBlob = - "H4sIAAAAAAAA/8vMTSzJzM9TSE7MLchJLElVyE9TyC9KSS1S0FFIz8hLz0ksSQUAtK7XMSYAAAA="; beforeEach(async () => { - jest.useFakeTimers(); + jest.clearAllMocks(); + + // Mock Request global if not available + if (typeof Request === "undefined") { + (global as any).Request = class { + constructor(public url: string) {} + }; + } + apiService = mock(); logService = mock(); + mockIndexedDbService = mock(); + + // Set default mock behaviors + mockIndexedDbService.hasUrl.mockResolvedValue(false); + mockIndexedDbService.loadAllUrls.mockResolvedValue([]); + mockIndexedDbService.findMatchingUrl.mockResolvedValue(false); + mockIndexedDbService.saveUrls.mockResolvedValue(undefined); + mockIndexedDbService.addUrls.mockResolvedValue(undefined); + mockIndexedDbService.saveUrlsFromStream.mockResolvedValue(undefined); platformUtilsService = mock(); platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); @@ -116,217 +57,370 @@ describe("PhishingDataService", () => { logService, platformUtilsService, ); - fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum"); - fetchAndCompressSpy = jest.spyOn(service as any, "fetchAndCompress"); + // Replace the IndexedDB service with our mock + service["indexedDbService"] = mockIndexedDbService; + + fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum"); fetchChecksumSpy.mockResolvedValue("new-checksum"); - fetchAndCompressSpy.mockResolvedValue("compressed-blob"); }); describe("initialization", () => { - beforeEach(() => { - jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob); - jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob); + it("should initialize with IndexedDB service", () => { + expect(service["indexedDbService"]).toBeDefined(); }); - it("should perform background update", async () => { - platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.x"); - jest - .spyOn(service as any, "getNextWebAddresses") - .mockResolvedValue({ meta: mockMeta, blob: mockBlob }); - - setMockBlob(mockBlob); - setMockMeta(mockMeta); - - const sub = service.update$.subscribe(); - await flushPromises(); - - const url = new URL("http://phish.com"); - const QAurl = new URL("http://phishing.testcategory.com"); + it("should detect QA test addresses - http protocol", async () => { + const url = new URL("http://phishing.testcategory.com"); expect(await service.isPhishingWebAddress(url)).toBe(true); - expect(await service.isPhishingWebAddress(QAurl)).toBe(true); + // IndexedDB should not be called for test addresses + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); + }); - sub.unsubscribe(); + it("should detect QA test addresses - https protocol", async () => { + const url = new URL("https://phishing.testcategory.com"); + expect(await service.isPhishingWebAddress(url)).toBe(true); + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); + }); + + it("should detect QA test addresses - specific subpath /block", async () => { + const url = new URL("https://phishing.testcategory.com/block"); + expect(await service.isPhishingWebAddress(url)).toBe(true); + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); + }); + + it("should NOT detect QA test addresses - different subpath", async () => { + mockIndexedDbService.hasUrl.mockResolvedValue(false); + mockIndexedDbService.findMatchingUrl.mockResolvedValue(false); + + const url = new URL("https://phishing.testcategory.com/other"); + const result = await service.isPhishingWebAddress(url); + + // This should NOT be detected as a test address since only /block subpath is hardcoded + expect(result).toBe(false); + }); + + it("should detect QA test addresses - root path with trailing slash", async () => { + const url = new URL("https://phishing.testcategory.com/"); + const result = await service.isPhishingWebAddress(url); + + // This SHOULD be detected since URLs are normalized (trailing slash added to root URLs) + expect(result).toBe(true); + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); }); }); describe("isPhishingWebAddress", () => { - beforeEach(() => { - jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob); - jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob); - }); + it("should detect a phishing web address using quick hasUrl lookup", async () => { + // Mock hasUrl to return true for direct hostname match + mockIndexedDbService.hasUrl.mockResolvedValue(true); - it("should detect a phishing web address", async () => { - service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); - - const url = new URL("http://phish.com"); + const url = new URL("http://phish.com/testing-param"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(true); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/testing-param"); + // Should not fall back to custom matcher when hasUrl returns true + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); + }); + + it("should return false when hasUrl returns false (custom matcher disabled)", async () => { + // Mock hasUrl to return false (no direct href match) + mockIndexedDbService.hasUrl.mockResolvedValue(false); + + const url = new URL("http://phish.com/path"); + const result = await service.isPhishingWebAddress(url); + + // Custom matcher is currently disabled (useCustomMatcher: false), so result is false + expect(result).toBe(false); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/path"); + // Custom matcher should NOT be called since it's disabled + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); it("should not detect a safe web address", async () => { - service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); + // Mock hasUrl to return false + mockIndexedDbService.hasUrl.mockResolvedValue(false); + const url = new URL("http://safe.com"); const result = await service.isPhishingWebAddress(url); + expect(result).toBe(false); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://safe.com/"); + // Custom matcher is disabled, so findMatchingUrl should NOT be called + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); - it("should match against root web address", async () => { - service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); - const url = new URL("http://phish.com/about"); + it("should not match against root web address with subpaths (custom matcher disabled)", async () => { + // Mock hasUrl to return false (no direct href match) + mockIndexedDbService.hasUrl.mockResolvedValue(false); + + const url = new URL("http://phish.com/login/page"); const result = await service.isPhishingWebAddress(url); - expect(result).toBe(true); + + expect(result).toBe(false); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page"); + // Custom matcher is disabled, so findMatchingUrl should NOT be called + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); - it("should not error on empty state", async () => { - service["_webAddressesSet"] = null; + it("should not match against root web address with different subpaths (custom matcher disabled)", async () => { + // Mock hasUrl to return false (no direct hostname match) + mockIndexedDbService.hasUrl.mockResolvedValue(false); + + const url = new URL("http://phish.com/login/page2"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(false); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page2"); + // Custom matcher is disabled, so findMatchingUrl should NOT be called + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); + }); + + it("should handle IndexedDB errors gracefully", async () => { + // Mock hasUrl to throw error + mockIndexedDbService.hasUrl.mockRejectedValue(new Error("hasUrl error")); + const url = new URL("http://phish.com/about"); const result = await service.isPhishingWebAddress(url); + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingDataService] IndexedDB lookup failed", + expect.any(Error), + ); + // Custom matcher is disabled, so no custom matcher error is expected + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); + }); + + it("should use cursor-based search when useCustomMatcher is enabled", async () => { + // Temporarily enable custom matcher for this test + const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER; + (PhishingDataService as any).USE_CUSTOM_MATCHER = true; + + try { + // Mock hasUrl to return false (no direct match) + mockIndexedDbService.hasUrl.mockResolvedValue(false); + // Mock findMatchingUrl to return true (custom matcher finds it) + mockIndexedDbService.findMatchingUrl.mockResolvedValue(true); + + const url = new URL("http://phish.com/path"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(true); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalled(); + expect(mockIndexedDbService.findMatchingUrl).toHaveBeenCalled(); + } finally { + // Restore original value + (PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue; + } + }); + + it("should return false when custom matcher finds no match (when enabled)", async () => { + const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER; + (PhishingDataService as any).USE_CUSTOM_MATCHER = true; + + try { + mockIndexedDbService.hasUrl.mockResolvedValue(false); + mockIndexedDbService.findMatchingUrl.mockResolvedValue(false); + + const url = new URL("http://safe.com/path"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(false); + expect(mockIndexedDbService.findMatchingUrl).toHaveBeenCalled(); + } finally { + (PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue; + } + }); + + it("should handle custom matcher errors gracefully (when enabled)", async () => { + const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER; + (PhishingDataService as any).USE_CUSTOM_MATCHER = true; + + try { + mockIndexedDbService.hasUrl.mockResolvedValue(false); + mockIndexedDbService.findMatchingUrl.mockRejectedValue(new Error("Cursor error")); + + const url = new URL("http://error.com/path"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingDataService] Custom matcher failed", + expect.any(Error), + ); + } finally { + (PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue; + } }); }); - describe("getNextWebAddresses", () => { - beforeEach(() => { - jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob); - jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob); + describe("data updates", () => { + it("should update full dataset via stream", async () => { + // Mock full dataset update + const mockResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); + + await firstValueFrom(service["_updateFullDataSet"]()); + + expect(mockIndexedDbService.saveUrlsFromStream).toHaveBeenCalled(); }); - it("refetches all web addresses if applicationVersion has changed", async () => { - const prev: PhishingDataMeta = { - timestamp: Date.now() - 60000, - checksum: "old", - applicationVersion: "1.0.0", - }; - fetchChecksumSpy.mockResolvedValue("new"); + it("should update daily dataset via addUrls", async () => { + // Mock daily update + const mockResponse = { + ok: true, + text: jest.fn().mockResolvedValue("newphish.com\nanotherbad.net"), + } as unknown as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); + + await firstValueFrom(service["_updateDailyDataSet"]()); + + expect(mockIndexedDbService.addUrls).toHaveBeenCalledWith(["newphish.com", "anotherbad.net"]); + }); + + it("should get updated meta information", async () => { + fetchChecksumSpy.mockResolvedValue("new-checksum"); platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0"); - const result = await service.getNextWebAddresses(prev); + const meta = await firstValueFrom(service["_getUpdatedMeta"]()); - expect(result!.blob).toBe("compressed-blob"); - expect(result!.meta!.checksum).toBe("new"); - expect(result!.meta!.applicationVersion).toBe("2.0.0"); - }); - - it("returns null when checksum matches and cache not expired", async () => { - const prev: PhishingDataMeta = { - timestamp: Date.now(), - checksum: "abc", - applicationVersion: "1.0.0", - }; - fetchChecksumSpy.mockResolvedValue("abc"); - const result = await service.getNextWebAddresses(prev); - expect(result).toBeNull(); - }); - - it("patches daily domains when cache is expired and checksum unchanged", async () => { - const prev: PhishingDataMeta = { - timestamp: 0, - checksum: "old", - applicationVersion: "1.0.0", - }; - const dailyLines = ["b.com", "c.com"]; - fetchChecksumSpy.mockResolvedValue("old"); - jest.spyOn(service as any, "fetchText").mockResolvedValue(dailyLines); - - setMockBlob(mockBlob); - - const expectedBlob = - "H4sIAAAAAAAA/8vMTSzJzM9TSE7MLchJLElVyE9TyC9KSS1S0FFIz8hLz0ksSQUAtK7XMSYAAAA="; - const result = await service.getNextWebAddresses(prev); - - expect(result!.blob).toBe(expectedBlob); - expect(result!.meta!.checksum).toBe("old"); - }); - - it("fetches all domains when checksum has changed", async () => { - const prev: PhishingDataMeta = { - timestamp: 0, - checksum: "old", - applicationVersion: "1.0.0", - }; - fetchChecksumSpy.mockResolvedValue("new"); - fetchAndCompressSpy.mockResolvedValue("new-blob"); - const result = await service.getNextWebAddresses(prev); - expect(result!.blob).toBe("new-blob"); - expect(result!.meta!.checksum).toBe("new"); + expect(meta).toBeDefined(); + expect(meta.checksum).toBe("new-checksum"); + expect(meta.applicationVersion).toBe("2.0.0"); + expect(meta.timestamp).toBeDefined(); }); }); - describe("compression helpers", () => { - let restore: () => void; + describe("phishing meta data updates", () => { + it("should not update metadata when no data updates occur", async () => { + // Set up existing metadata + const existingMeta = { + checksum: "existing-checksum", + timestamp: Date.now() - 1000, // 1 second ago (not expired) + applicationVersion: "1.0.0", + }; + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); - beforeEach(async () => { - restore = setupPhishingMocks("abc"); + // Mock conditions where no update is needed + fetchChecksumSpy.mockResolvedValue("existing-checksum"); // Same checksum + platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); // Same version + const mockResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); + + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); + + // Verify metadata was NOT updated (same reference returned) + expect(result).toEqual(existingMeta); + expect(result?.timestamp).toBe(existingMeta.timestamp); + + // Verify no data updates were performed + expect(mockIndexedDbService.saveUrlsFromStream).not.toHaveBeenCalled(); + expect(mockIndexedDbService.addUrls).not.toHaveBeenCalled(); }); - afterEach(() => { - if (restore) { - restore(); - } - delete (Uint8Array as any).fromBase64; - jest.restoreAllMocks(); + it("should update metadata when full dataset update occurs due to checksum change", async () => { + // Set up existing metadata + const existingMeta = { + checksum: "old-checksum", + timestamp: Date.now() - 1000, + applicationVersion: "1.0.0", + }; + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); + + // Mock conditions for full update + fetchChecksumSpy.mockResolvedValue("new-checksum"); // Different checksum + platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); + const mockResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); + + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); + + // Verify metadata WAS updated with new values + expect(result?.checksum).toBe("new-checksum"); + expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp); + + // Verify full update was performed + expect(mockIndexedDbService.saveUrlsFromStream).toHaveBeenCalled(); + expect(mockIndexedDbService.addUrls).not.toHaveBeenCalled(); // Daily should not run }); - describe("_compressString", () => { - it("compresses a string to base64", async () => { - const out = await service["_compressString"]("abc"); - expect(out).toBe("YWJj"); // base64 for 'abc' - }); + it("should update metadata when full dataset update occurs due to version change", async () => { + // Set up existing metadata + const existingMeta = { + checksum: "same-checksum", + timestamp: Date.now() - 1000, + applicationVersion: "1.0.0", + }; + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); - it("compresses using fallback on older browsers", async () => { - const input = "abc"; - const expected = btoa(encodeURIComponent(input)); - const out = await service["_compressString"](input); - expect(out).toBe(expected); - }); + // Mock conditions for full update + fetchChecksumSpy.mockResolvedValue("same-checksum"); + platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0"); // Different version + const mockResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); - it("compresses using btoa on error", async () => { - const input = "abc"; - const expected = btoa(encodeURIComponent(input)); - const out = await service["_compressString"](input); - expect(out).toBe(expected); - }); + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); + + // Verify metadata WAS updated + expect(result?.applicationVersion).toBe("2.0.0"); + expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp); + + // Verify full update was performed + expect(mockIndexedDbService.saveUrlsFromStream).toHaveBeenCalled(); + expect(mockIndexedDbService.addUrls).not.toHaveBeenCalled(); }); - describe("_decompressString", () => { - it("decompresses a string from base64", async () => { - const base64 = btoa("ignored"); - const out = await service["_decompressString"](base64); - expect(out).toBe("abc"); - }); - it("decompresses using fallback on older browsers", async () => { - // Provide a fromBase64 implementation - (Uint8Array as any).fromBase64 = (b64: string) => new Uint8Array([100, 101, 102]); + it("should update metadata when daily update occurs due to cache expiration", async () => { + // Set up existing metadata (expired cache) + const existingMeta = { + checksum: "same-checksum", + timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago (expired) + applicationVersion: "1.0.0", + }; + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); - const out = await service["_decompressString"]("ignored"); - expect(out).toBe("abc"); - }); + // Mock conditions for daily update only + fetchChecksumSpy.mockResolvedValue("same-checksum"); // Same checksum (no full update) + platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); // Same version + const mockFullResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + const mockDailyResponse = { + ok: true, + text: jest.fn().mockResolvedValue("newdomain.com"), + } as unknown as Response; + apiService.nativeFetch + .mockResolvedValueOnce(mockFullResponse) + .mockResolvedValueOnce(mockDailyResponse); - it("decompresses using atob on error", async () => { - const base64 = btoa(encodeURIComponent("abc")); - const out = await service["_decompressString"](base64); - expect(out).toBe("abc"); - }); - }); - }); + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); - describe("_loadBlobToMemory", () => { - it("loads blob into memory set", async () => { - const prevBlob = "ignored-base64"; - fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_BLOB_KEY).stateSubject.next(prevBlob); + // Verify metadata WAS updated + expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp); + expect(result?.checksum).toBe("same-checksum"); - jest.spyOn(service as any, "_decompressString").mockResolvedValue("phish.com\nbadguy.net"); - - // Trigger the load pipeline and allow async RxJS processing to complete - service["_loadBlobToMemory"](); - await flushPromises(); - - const set = service["_webAddressesSet"] as Set; - expect(set).toBeDefined(); - expect(set.has("phish.com")).toBe(true); - expect(set.has("badguy.net")).toBe(true); + // Verify only daily update was performed + expect(mockIndexedDbService.saveUrlsFromStream).not.toHaveBeenCalled(); + expect(mockIndexedDbService.addUrls).toHaveBeenCalledWith(["newdomain.com"]); }); }); }); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts index 7d5f04cc276..03759ba14bc 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -1,17 +1,25 @@ import { catchError, + concatMap, + defer, EMPTY, + exhaustMap, first, - firstValueFrom, + forkJoin, from, + iif, + map, + Observable, of, + retry, share, takeUntil, startWith, Subject, switchMap, tap, - map, + throwError, + timer, } from "rxjs"; import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags"; @@ -23,6 +31,8 @@ import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bi import { getPhishingResources, PhishingResourceType } from "../phishing-resources"; +import { PhishingIndexedDbService } from "./phishing-indexeddb.service"; + /** * Metadata about the phishing data set */ @@ -68,24 +78,25 @@ export const PHISHING_DOMAINS_BLOB_KEY = new KeyDefinition( /** Coordinates fetching, caching, and patching of known phishing web addresses */ export class PhishingDataService { + // Cursor-based search is disabled due to performance (6+ minutes on large databases) + // Enable when performance is optimized via indexing or other improvements + private static readonly USE_CUSTOM_MATCHER = false; + // While background scripts do not necessarily need destroying, // processes in PhishingDataService are memory intensive. // We are adding the destroy to guard against accidental leaks. private _destroy$ = new Subject(); - private _testWebAddresses = this.getTestWebAddresses().concat("phishing.testcategory.com"); // Included for QA to test in prod + private _testWebAddresses = this.getTestWebAddresses(); private _phishingMetaState = this.globalStateProvider.get(PHISHING_DOMAINS_META_KEY); - private _phishingBlobState = this.globalStateProvider.get(PHISHING_DOMAINS_BLOB_KEY); - // In-memory set loaded from blob for fast lookups without reading large storage repeatedly - private _webAddressesSet: Set | null = null; - // Loading variables for web addresses set - // Triggers a load for _webAddressesSet - private _loadTrigger$ = new Subject(); + private indexedDbService: PhishingIndexedDbService; // How often are new web addresses added to the remote? readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours + private _backgroundUpdateTrigger$ = new Subject(); + private _triggerUpdate$ = new Subject(); update$ = this._triggerUpdate$.pipe( startWith(undefined), // Always emit once @@ -93,12 +104,8 @@ export class PhishingDataService { this._phishingMetaState.state$.pipe( first(), // Only take the first value to avoid an infinite loop when updating the cache below tap((metaState) => { - // Initial loading of web addresses set if not already loaded - if (!this._webAddressesSet) { - this._loadBlobToMemory(); - } - // Perform any updates in the background if needed - void this._backgroundUpdate(metaState); + // Perform any updates in the background + this._backgroundUpdateTrigger$.next(metaState); }), catchError((err: unknown) => { this.logService.error("[PhishingDataService] Background update failed to start.", err); @@ -106,7 +113,6 @@ export class PhishingDataService { }), ), ), - // Stop emitting when dispose() is called takeUntil(this._destroy$), share(), ); @@ -120,6 +126,7 @@ export class PhishingDataService { private resourceType: PhishingResourceType = PhishingResourceType.Links, ) { this.logService.debug("[PhishingDataService] Initializing service..."); + this.indexedDbService = new PhishingIndexedDbService(this.logService); this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => { this._triggerUpdate$.next(); }); @@ -127,18 +134,20 @@ export class PhishingDataService { ScheduledTaskNames.phishingDomainUpdate, this.UPDATE_INTERVAL_DURATION, ); - this._setupLoadPipeline(); + this._backgroundUpdateTrigger$ + .pipe( + exhaustMap((currentMeta) => { + return this._backgroundUpdate(currentMeta); + }), + takeUntil(this._destroy$), + ) + .subscribe(); } dispose(): void { // Signal all pipelines to stop and unsubscribe stored subscriptions this._destroy$.next(); this._destroy$.complete(); - - // Clear web addresses set from memory - if (this._webAddressesSet !== null) { - this._webAddressesSet = null; - } } /** @@ -148,105 +157,84 @@ export class PhishingDataService { * @returns True if the URL is a known phishing web address, false otherwise */ async isPhishingWebAddress(url: URL): Promise { - if (!this._webAddressesSet) { - this.logService.debug("[PhishingDataService] Set not loaded; skipping check"); + // Skip non-http(s) protocols - phishing database only contains web URLs + if (url.protocol !== "http:" && url.protocol !== "https:") { return false; } - const set = this._webAddressesSet!; + // Quick check for QA/dev test addresses + if (this._testWebAddresses.includes(url.href)) { + this.logService.info("[PhishingDataService] Found test web address: " + url.href); + return true; + } + const resource = getPhishingResources(this.resourceType); - // Custom matcher per resource - if (resource && resource?.match) { - for (const entry of set) { - if (resource.match(url, entry)) { - return true; - } + try { + // Quick lookup: check direct presence of href in IndexedDB + // Also check without trailing slash since browsers add it but DB entries may not have it + const urlHref = url.href; + const urlWithoutTrailingSlash = urlHref.endsWith("/") ? urlHref.slice(0, -1) : null; + + let hasUrl = await this.indexedDbService.hasUrl(urlHref); + + if (!hasUrl && urlWithoutTrailingSlash) { + hasUrl = await this.indexedDbService.hasUrl(urlWithoutTrailingSlash); } - return false; + + if (hasUrl) { + this.logService.info("[PhishingDataService] Found phishing URL: " + urlHref); + return true; + } + } catch (err) { + this.logService.error("[PhishingDataService] IndexedDB lookup failed", err); } - // Default set-based lookup - return set.has(url.hostname); - } - - async getNextWebAddresses( - previous: PhishingDataMeta | null, - ): Promise | null> { - const prevMeta = previous ?? { timestamp: 0, checksum: "", applicationVersion: "" }; - const now = Date.now(); - - // Updates to check - const applicationVersion = await this.platformUtilsService.getApplicationVersion(); - const remoteChecksum = await this.fetchPhishingChecksum(this.resourceType); - - // Logic checks - const appVersionChanged = applicationVersion !== prevMeta.applicationVersion; - const masterChecksumChanged = remoteChecksum !== prevMeta.checksum; - - // Check for full updated - if (masterChecksumChanged || appVersionChanged) { - this.logService.info("[PhishingDataService] Checksum or version changed; Fetching ALL."); - const remoteUrl = getPhishingResources(this.resourceType)!.remoteUrl; - const blob = await this.fetchAndCompress(remoteUrl); - return { - blob, - meta: { checksum: remoteChecksum, timestamp: now, applicationVersion }, - }; - } - - // Check for daily file - const isCacheExpired = now - prevMeta.timestamp > this.UPDATE_INTERVAL_DURATION; - - if (isCacheExpired) { - this.logService.info("[PhishingDataService] Daily cache expired; Fetching TODAY's"); - const url = getPhishingResources(this.resourceType)!.todayUrl; - const newLines = await this.fetchText(url); - const prevBlob = (await firstValueFrom(this._phishingBlobState.state$)) ?? ""; - const oldText = prevBlob ? await this._decompressString(prevBlob) : ""; - - // Join the new lines to the existing list - const combined = (oldText ? oldText + "\n" : "") + newLines.join("\n"); - - return { - blob: await this._compressString(combined), - meta: { - checksum: remoteChecksum, - timestamp: now, // Reset the timestamp - applicationVersion, - }, - }; - } - - return null; + // Custom matcher is disabled for performance (see USE_CUSTOM_MATCHER) + if (resource && resource.match && PhishingDataService.USE_CUSTOM_MATCHER) { + try { + const found = await this.indexedDbService.findMatchingUrl((entry) => + resource.match(url, entry), + ); + + if (found) { + this.logService.info("[PhishingDataService] Found phishing URL via matcher: " + url.href); + } + return found; + } catch (err) { + this.logService.error("[PhishingDataService] Custom matcher failed", err); + return false; + } + } + + return false; } + // [FIXME] Pull fetches into api service private async fetchPhishingChecksum(type: PhishingResourceType = PhishingResourceType.Domains) { const checksumUrl = getPhishingResources(type)!.checksumUrl; - const response = await this.apiService.nativeFetch(new Request(checksumUrl)); - if (!response.ok) { - throw new Error(`[PhishingDataService] Failed to fetch checksum: ${response.status}`); - } - return response.text(); - } - private async fetchAndCompress(url: string): Promise { - const response = await this.apiService.nativeFetch(new Request(url)); - if (!response.ok) { - throw new Error("Fetch failed"); - } + this.logService.debug(`[PhishingDataService] Fetching checksum from: ${checksumUrl}`); - const downloadStream = response.body!; - // Pipe through CompressionStream while it's downloading - const compressedStream = downloadStream.pipeThrough(new CompressionStream("gzip")); - // Convert to ArrayBuffer - const buffer = await new Response(compressedStream).arrayBuffer(); - const bytes = new Uint8Array(buffer); + try { + const response = await this.apiService.nativeFetch(new Request(checksumUrl)); + if (!response.ok) { + throw new Error( + `[PhishingDataService] Failed to fetch checksum: ${response.status} ${response.statusText}`, + ); + } - // Return as Base64 for storage - return (bytes as any).toBase64 ? (bytes as any).toBase64() : this._uint8ToBase64Fallback(bytes); + return await response.text(); + } catch (error) { + this.logService.error( + `[PhishingDataService] Checksum fetch failed from ${checksumUrl}`, + error, + ); + throw error; + } } - private async fetchText(url: string) { + // [FIXME] Pull fetches into api service + private async fetchToday(url: string) { const response = await this.apiService.nativeFetch(new Request(url)); if (!response.ok) { @@ -258,171 +246,196 @@ export class PhishingDataService { private getTestWebAddresses() { const flag = devFlagEnabled("testPhishingUrls"); + // Normalize URLs by converting to URL object and back to ensure consistent format (e.g., trailing slashes) + const testWebAddresses: string[] = [ + new URL("http://phishing.testcategory.com").href, + new URL("https://phishing.testcategory.com").href, + new URL("https://phishing.testcategory.com/block").href, + ]; if (!flag) { - return []; + return testWebAddresses; } const webAddresses = devFlagValue("testPhishingUrls") as unknown[]; if (webAddresses && webAddresses instanceof Array) { this.logService.debug( - "[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:", + "[PhishingDataService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:", webAddresses, ); - return webAddresses as string[]; + // Normalize dev flag URLs as well, filtering out invalid ones + const normalizedDevAddresses = (webAddresses as string[]) + .filter((addr) => { + try { + new URL(addr); + return true; + } catch { + this.logService.warning( + `[PhishingDataService] Invalid test URL in dev flag, skipping: ${addr}`, + ); + return false; + } + }) + .map((addr) => new URL(addr).href); + return testWebAddresses.concat(normalizedDevAddresses); } - return []; + return testWebAddresses; } - // Runs the update flow in the background and retries up to 3 times on failure - private async _backgroundUpdate(previous: PhishingDataMeta | null): Promise { - this.logService.info(`[PhishingDataService] Update web addresses triggered...`); - const phishingMeta: PhishingDataMeta = previous ?? { - timestamp: 0, - checksum: "", - applicationVersion: "", - }; - // Start time for logging performance of update - const startTime = Date.now(); - const maxAttempts = 3; - const delayMs = 5 * 60 * 1000; // 5 minutes + private _getUpdatedMeta(): Observable { + return defer(() => { + const now = Date.now(); - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const next = await this.getNextWebAddresses(phishingMeta); - if (!next) { - return; // No update needed - } + return forkJoin({ + applicationVersion: from(this.platformUtilsService.getApplicationVersion()), + remoteChecksum: from(this.fetchPhishingChecksum(this.resourceType)), + }).pipe( + map(({ applicationVersion, remoteChecksum }) => { + return { + checksum: remoteChecksum, + timestamp: now, + applicationVersion, + }; + }), + ); + }); + } - if (next.meta) { - await this._phishingMetaState.update(() => next!.meta!); - } - if (next.blob) { - await this._phishingBlobState.update(() => next!.blob!); - this._loadBlobToMemory(); - } + // Streams the full phishing data set and saves it to IndexedDB + private _updateFullDataSet() { + const resource = getPhishingResources(this.resourceType); - // Performance logging - const elapsed = Date.now() - startTime; - this.logService.info(`[PhishingDataService] Phishing data cache updated in ${elapsed}ms`); - } catch (err) { - this.logService.error( - `[PhishingDataService] Unable to update web addresses. Attempt ${attempt}.`, - err, - ); - if (attempt < maxAttempts) { - await new Promise((res) => setTimeout(res, delayMs)); - } else { - const elapsed = Date.now() - startTime; - this.logService.error( - `[PhishingDataService] Retries unsuccessful after ${elapsed}ms. Unable to update web addresses.`, - err, + if (!resource?.remoteUrl) { + return throwError(() => new Error("Invalid resource URL")); + } + + this.logService.info(`[PhishingDataService] Starting FULL update using ${resource.remoteUrl}`); + return from(this.apiService.nativeFetch(new Request(resource.remoteUrl))).pipe( + switchMap((response) => { + if (!response.ok || !response.body) { + return throwError( + () => + new Error( + `[PhishingDataService] Full fetch failed: ${response.status}, ${response.statusText}`, + ), ); } - } - } + + return from(this.indexedDbService.saveUrlsFromStream(response.body)); + }), + catchError((err: unknown) => { + this.logService.error( + `[PhishingDataService] Full dataset update failed using primary source ${err}`, + ); + this.logService.warning( + `[PhishingDataService] Falling back to: ${resource.fallbackUrl} (Note: Fallback data may be less up-to-date)`, + ); + // Try fallback URL + return from(this.apiService.nativeFetch(new Request(resource.fallbackUrl))).pipe( + switchMap((fallbackResponse) => { + if (!fallbackResponse.ok || !fallbackResponse.body) { + return throwError( + () => + new Error( + `[PhishingDataService] Fallback fetch failed: ${fallbackResponse.status}, ${fallbackResponse.statusText}`, + ), + ); + } + + return from(this.indexedDbService.saveUrlsFromStream(fallbackResponse.body)); + }), + catchError((fallbackError: unknown) => { + this.logService.error(`[PhishingDataService] Fallback source failed`); + return throwError(() => fallbackError); + }), + ); + }), + ); } - // Sets up the load pipeline to load the blob into memory when triggered - private _setupLoadPipeline(): void { - this._loadTrigger$ - .pipe( - switchMap(() => - this._phishingBlobState.state$.pipe( - first(), - switchMap((blobBase64) => { - if (!blobBase64) { - return of(undefined); - } - // Note: _decompressString wraps a promise that cannot be aborted - // If performance improvements are needed, consider migrating to a cancellable approach - return from(this._decompressString(blobBase64)).pipe( - map((text) => { - const lines = text.split(/\r?\n/); - const newWebAddressesSet = new Set(lines); - this._testWebAddresses.forEach((a) => newWebAddressesSet.add(a)); - this._webAddressesSet = new Set(newWebAddressesSet); - this.logService.info( - `[PhishingDataService] loaded ${this._webAddressesSet.size} addresses into memory from blob`, - ); - }), + private _updateDailyDataSet() { + this.logService.info("[PhishingDataService] Starting DAILY update..."); + + const todayUrl = getPhishingResources(this.resourceType)?.todayUrl; + if (!todayUrl) { + return throwError(() => new Error("Today URL missing")); + } + + return from(this.fetchToday(todayUrl)).pipe( + switchMap((lines) => from(this.indexedDbService.addUrls(lines))), + ); + } + + private _backgroundUpdate( + previous: PhishingDataMeta | null, + ): Observable { + // Use defer to restart timer if retry is activated + return defer(() => { + const startTime = Date.now(); + this.logService.info(`[PhishingDataService] Update triggered...`); + + // Get updated meta info + return this._getUpdatedMeta().pipe( + // Update full data set if application version or checksum changed + concatMap((newMeta) => + iif( + () => { + const appVersionChanged = newMeta.applicationVersion !== previous?.applicationVersion; + const checksumChanged = newMeta.checksum !== previous?.checksum; + + this.logService.info( + `[PhishingDataService] Checking if full update is needed: appVersionChanged=${appVersionChanged}, checksumChanged=${checksumChanged}`, ); - }), - catchError((err: unknown) => { - this.logService.error("[PhishingDataService] Failed to load blob into memory", err); - return of(undefined); - }), + return appVersionChanged || checksumChanged; + }, + this._updateFullDataSet().pipe(map(() => ({ meta: newMeta, updated: true }))), + of({ meta: newMeta, updated: false }), ), ), - catchError((err: unknown) => { - this.logService.error("[PhishingDataService] Load pipeline failed", err); - return of(undefined); + // Update daily data set if last update was more than UPDATE_INTERVAL_DURATION ago + concatMap((result) => + iif( + () => { + const isCacheExpired = + Date.now() - (previous?.timestamp ?? 0) > this.UPDATE_INTERVAL_DURATION; + return isCacheExpired; + }, + this._updateDailyDataSet().pipe(map(() => ({ meta: result.meta, updated: true }))), + of(result), + ), + ), + concatMap((result) => { + if (!result.updated) { + this.logService.debug(`[PhishingDataService] No update needed, metadata unchanged`); + return of(previous); + } + + this.logService.debug(`[PhishingDataService] Updated phishing meta data:`, result.meta); + return from(this._phishingMetaState.update(() => result.meta)).pipe( + tap(() => { + const elapsed = Date.now() - startTime; + this.logService.info(`[PhishingDataService] Updated data set in ${elapsed}ms`); + }), + ); }), - takeUntil(this._destroy$), - share(), - ) - .subscribe(); - } - - // [FIXME] Move compression helpers to a shared utils library - // to separate from phishing data service. - // ------------------------- Blob and Compression Handling ------------------------- - private async _compressString(input: string): Promise { - try { - const stream = new Blob([input]).stream().pipeThrough(new CompressionStream("gzip")); - - const compressedBuffer = await new Response(stream).arrayBuffer(); - const bytes = new Uint8Array(compressedBuffer); - - // Modern browsers support direct toBase64 conversion - // For older support, use fallback - return (bytes as any).toBase64 - ? (bytes as any).toBase64() - : this._uint8ToBase64Fallback(bytes); - } catch (err) { - this.logService.error("[PhishingDataService] Compression failed", err); - return btoa(encodeURIComponent(input)); - } - } - - private async _decompressString(base64: string): Promise { - try { - // Modern browsers support direct toBase64 conversion - // For older support, use fallback - const bytes = (Uint8Array as any).fromBase64 - ? (Uint8Array as any).fromBase64(base64) - : this._base64ToUint8Fallback(base64); - if (bytes == null) { - throw new Error("Base64 decoding resulted in null"); - } - const byteResponse = new Response(bytes); - if (!byteResponse.body) { - throw new Error("Response body is null"); - } - const stream = byteResponse.body.pipeThrough(new DecompressionStream("gzip")); - const streamResponse = new Response(stream); - return await streamResponse.text(); - } catch (err) { - this.logService.error("[PhishingDataService] Decompression failed", err); - return decodeURIComponent(atob(base64)); - } - } - - // Trigger a load of the blob into memory - private _loadBlobToMemory(): void { - this._loadTrigger$.next(); - } - private _uint8ToBase64Fallback(bytes: Uint8Array): string { - const CHUNK_SIZE = 0x8000; // 32KB chunks - let binary = ""; - for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { - const chunk = bytes.subarray(i, i + CHUNK_SIZE); - binary += String.fromCharCode.apply(null, chunk as any); - } - return btoa(binary); - } - - private _base64ToUint8Fallback(base64: string): Uint8Array { - const binary = atob(base64); - return Uint8Array.from(binary, (c) => c.charCodeAt(0)); + retry({ + count: 2, // Total 3 attempts (initial + 2 retries) + delay: (error, retryCount) => { + this.logService.error( + `[PhishingDataService] Attempt ${retryCount} failed. Retrying in 5m...`, + error, + ); + return timer(5 * 60 * 1000); // Wait 5 mins before next attempt + }, + }), + catchError((err: unknown) => { + const elapsed = Date.now() - startTime; + this.logService.error( + `[PhishingDataService] Retries unsuccessful after ${elapsed}ms.`, + err, + ); + return of(previous); + }), + ); + }); } } diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index 815007e1d4c..2fa7bf8ec9e 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -1,14 +1,4 @@ -import { - concatMap, - distinctUntilChanged, - EMPTY, - filter, - map, - merge, - Subject, - switchMap, - tap, -} from "rxjs"; +import { distinctUntilChanged, EMPTY, filter, map, merge, Subject, switchMap, tap } from "rxjs"; import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -63,7 +53,7 @@ export class PhishingDetectionService { tap((message) => logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`), ), - concatMap(async (message) => { + switchMap(async (message) => { const url = new URL(message.url); this._ignoredHostnames.add(url.hostname); await BrowserApi.navigateTabToUrl(message.tabId, url); @@ -88,7 +78,9 @@ export class PhishingDetectionService { prev.ignored === curr.ignored, ), tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)), - concatMap(async ({ tabId, url, ignored }) => { + // Use switchMap to cancel any in-progress check when navigating to a new URL + // This prevents race conditions where a stale check redirects the user incorrectly + switchMap(async ({ tabId, url, ignored }) => { if (ignored) { // The next time this host is visited, block again this._ignoredHostnames.delete(url.hostname); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts index 75bd634b1fc..98835a5b366 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts @@ -215,6 +215,86 @@ describe("PhishingIndexedDbService", () => { }); }); + describe("addUrls", () => { + it("appends URLs to IndexedDB without clearing", async () => { + // Pre-populate store with existing data + mockStore.set("https://existing.com", { url: "https://existing.com" }); + + const urls = ["https://phishing.com", "https://malware.net"]; + const result = await service.addUrls(urls); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readwrite"); + expect(mockObjectStore.clear).not.toHaveBeenCalled(); + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + // Existing data should still be present + expect(mockStore.has("https://existing.com")).toBe(true); + expect(mockStore.size).toBe(3); + expect(mockDb.close).toHaveBeenCalled(); + }); + + it("handles empty array without clearing", async () => { + mockStore.set("https://existing.com", { url: "https://existing.com" }); + + const result = await service.addUrls([]); + + expect(result).toBe(true); + expect(mockObjectStore.clear).not.toHaveBeenCalled(); + expect(mockStore.has("https://existing.com")).toBe(true); + }); + + it("trims whitespace from URLs", async () => { + const urls = [" https://example.com ", "\nhttps://test.org\n"]; + + await service.addUrls(urls); + + expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://example.com" }); + expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://test.org" }); + }); + + it("skips empty lines", async () => { + const urls = ["https://example.com", "", " ", "https://test.org"]; + + await service.addUrls(urls); + + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + }); + + it("handles duplicate URLs via upsert", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + + const urls = [ + "https://example.com", // Already exists + "https://test.org", + ]; + + const result = await service.addUrls(urls); + + expect(result).toBe(true); + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + expect(mockStore.size).toBe(2); + }); + + it("logs error and returns false on failure", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const result = await service.addUrls(["https://test.com"]); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Add failed", + expect.any(Error), + ); + }); + }); + describe("hasUrl", () => { it("returns true for existing URL", async () => { mockStore.set("https://example.com", { url: "https://example.com" }); @@ -355,6 +435,89 @@ describe("PhishingIndexedDbService", () => { }); }); + describe("findMatchingUrl", () => { + it("returns true when matcher finds a match", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + mockStore.set("https://phishing.net", { url: "https://phishing.net" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + + const matcher = (url: string) => url.includes("phishing"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readonly"); + expect(mockObjectStore.openCursor).toHaveBeenCalled(); + }); + + it("returns false when no URLs match", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + + const matcher = (url: string) => url.includes("notfound"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(false); + }); + + it("returns false when store is empty", async () => { + const matcher = (url: string) => url.includes("anything"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(false); + }); + + it("exits early on first match without iterating all records", async () => { + mockStore.set("https://match1.com", { url: "https://match1.com" }); + mockStore.set("https://match2.com", { url: "https://match2.com" }); + mockStore.set("https://match3.com", { url: "https://match3.com" }); + + const matcherCallCount = jest + .fn() + .mockImplementation((url: string) => url.includes("match2")); + await service.findMatchingUrl(matcherCallCount); + + // Matcher should be called for match1.com and match2.com, but NOT match3.com + // because it exits early on first match + expect(matcherCallCount).toHaveBeenCalledWith("https://match1.com"); + expect(matcherCallCount).toHaveBeenCalledWith("https://match2.com"); + expect(matcherCallCount).not.toHaveBeenCalledWith("https://match3.com"); + expect(matcherCallCount).toHaveBeenCalledTimes(2); + }); + + it("supports complex matcher logic", async () => { + mockStore.set("https://example.com/path", { url: "https://example.com/path" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + mockStore.set("https://phishing.net/login", { url: "https://phishing.net/login" }); + + const matcher = (url: string) => { + return url.includes("phishing") && url.includes("login"); + }; + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(true); + }); + + it("returns false on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const matcher = (url: string) => url.includes("test"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Cursor search failed", + expect.any(Error), + ); + }); + }); + describe("database initialization", () => { it("creates object store with keyPath on upgrade", async () => { mockDb.objectStoreNames.contains.mockReturnValue(false); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts index 099839a38d9..ea4b7987607 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts @@ -53,6 +53,9 @@ export class PhishingIndexedDbService { * @returns `true` if save succeeded, `false` on error */ async saveUrls(urls: string[]): Promise { + this.logService.debug( + `[PhishingIndexedDbService] Clearing and saving ${urls.length} to the store...`, + ); let db: IDBDatabase | null = null; try { db = await this.openDatabase(); @@ -67,6 +70,29 @@ export class PhishingIndexedDbService { } } + /** + * Adds an array of phishing URLs to IndexedDB. + * Appends to existing data without clearing. + * + * @param urls - Array of phishing URLs to add + * @returns `true` if add succeeded, `false` on error + */ + async addUrls(urls: string[]): Promise { + this.logService.debug(`[PhishingIndexedDbService] Adding ${urls.length} to the store...`); + + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + await this.saveChunked(db, urls); + return true; + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Add failed", error); + return false; + } finally { + db?.close(); + } + } + /** * Saves URLs in chunks to prevent transaction timeouts and UI freezes. */ @@ -100,6 +126,8 @@ export class PhishingIndexedDbService { * @returns `true` if URL exists, `false` if not found or on error */ async hasUrl(url: string): Promise { + this.logService.debug(`[PhishingIndexedDbService] Checking if store contains ${url}...`); + let db: IDBDatabase | null = null; try { db = await this.openDatabase(); @@ -130,6 +158,8 @@ export class PhishingIndexedDbService { * @returns Array of all stored URLs, or empty array on error */ async loadAllUrls(): Promise { + this.logService.debug("[PhishingIndexedDbService] Loading all urls from store..."); + let db: IDBDatabase | null = null; try { db = await this.openDatabase(); @@ -165,6 +195,60 @@ export class PhishingIndexedDbService { }); } + /** + * Checks if any URL in the database matches the given matcher function. + * Uses a cursor to iterate through records without loading all into memory. + * Returns immediately on first match for optimal performance. + * + * @param matcher - Function that tests each URL and returns true if it matches + * @returns `true` if any URL matches, `false` if none match or on error + */ + async findMatchingUrl(matcher: (url: string) => boolean): Promise { + this.logService.debug("[PhishingIndexedDbService] Searching for matching URL with cursor..."); + + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + return await this.cursorSearch(db, matcher); + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Cursor search failed", error); + return false; + } finally { + db?.close(); + } + } + + /** + * Performs cursor-based search through all URLs. + * Tests each URL with the matcher without accumulating records in memory. + */ + private cursorSearch(db: IDBDatabase, matcher: (url: string) => boolean): Promise { + return new Promise((resolve, reject) => { + const req = db + .transaction(this.STORE_NAME, "readonly") + .objectStore(this.STORE_NAME) + .openCursor(); + req.onerror = () => reject(req.error); + req.onsuccess = (e) => { + const cursor = (e.target as IDBRequest).result; + if (cursor) { + const url = (cursor.value as PhishingUrlRecord).url; + // Test the URL immediately without accumulating in memory + if (matcher(url)) { + // Found a match + resolve(true); + return; + } + // No match, continue to next record + cursor.continue(); + } else { + // Reached end of records without finding a match + resolve(false); + } + }; + }); + } + /** * Saves phishing URLs directly from a stream. * Processes data incrementally to minimize memory usage. @@ -173,11 +257,16 @@ export class PhishingIndexedDbService { * @returns `true` if save succeeded, `false` on error */ async saveUrlsFromStream(stream: ReadableStream): Promise { + this.logService.debug("[PhishingIndexedDbService] Saving urls to the store from stream..."); + let db: IDBDatabase | null = null; try { db = await this.openDatabase(); await this.clearStore(db); await this.processStream(db, stream); + this.logService.info( + "[PhishingIndexedDbService] Finished saving urls to the store from stream.", + ); return true; } catch (error) { this.logService.error("[PhishingIndexedDbService] Stream save failed", error); diff --git a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts index c8be58b0bde..d7e755b34ea 100644 --- a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts +++ b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts @@ -35,7 +35,7 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { super(); // Always connect to the native messaging background if biometrics are enabled, not just when it is used // so that there is no wait when used. - const biometricsEnabled = this.biometricStateService.biometricUnlockEnabled$; + const biometricsEnabled = this.biometricStateService.biometricUnlockEnabled$(); combineLatest([timer(0, this.BACKGROUND_POLLING_INTERVAL), biometricsEnabled]) .pipe( diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts index 7678b65d29e..934fb9307ee 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts @@ -14,7 +14,7 @@ import { BiometricsStatus, BiometricStateService, } from "@bitwarden/key-management"; -import { UnlockOptions } from "@bitwarden/key-management-ui"; +import { UnlockOptions, WebAuthnPrfUnlockService } from "@bitwarden/key-management-ui"; import { BrowserApi } from "../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; @@ -34,6 +34,7 @@ describe("ExtensionLockComponentService", () => { let vaultTimeoutSettingsService: MockProxy; let routerService: MockProxy; let biometricStateService: MockProxy; + let webAuthnPrfUnlockService: MockProxy; beforeEach(() => { userDecryptionOptionsService = mock(); @@ -43,37 +44,21 @@ describe("ExtensionLockComponentService", () => { vaultTimeoutSettingsService = mock(); routerService = mock(); biometricStateService = mock(); + webAuthnPrfUnlockService = mock(); TestBed.configureTestingModule({ providers: [ - ExtensionLockComponentService, { - provide: UserDecryptionOptionsServiceAbstraction, - useValue: userDecryptionOptionsService, - }, - { - provide: PlatformUtilsService, - useValue: platformUtilsService, - }, - { - provide: BiometricsService, - useValue: biometricsService, - }, - { - provide: PinServiceAbstraction, - useValue: pinService, - }, - { - provide: VaultTimeoutSettingsService, - useValue: vaultTimeoutSettingsService, - }, - { - provide: BrowserRouterService, - useValue: routerService, - }, - { - provide: BiometricStateService, - useValue: biometricStateService, + provide: ExtensionLockComponentService, + useFactory: () => + new ExtensionLockComponentService( + userDecryptionOptionsService, + biometricsService, + pinService, + biometricStateService, + routerService, + webAuthnPrfUnlockService, + ), }, ], }); @@ -212,6 +197,9 @@ describe("ExtensionLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -234,6 +222,9 @@ describe("ExtensionLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -256,6 +247,9 @@ describe("ExtensionLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -278,6 +272,9 @@ describe("ExtensionLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -300,6 +297,9 @@ describe("ExtensionLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.UnlockNeeded, }, + prf: { + enabled: false, + }, }, ], [ @@ -322,6 +322,9 @@ describe("ExtensionLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledInConnectedDesktopApp, }, + prf: { + enabled: false, + }, }, ], [ @@ -344,6 +347,9 @@ describe("ExtensionLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.HardwareUnavailable, }, + prf: { + enabled: false, + }, }, ], ]; @@ -369,14 +375,18 @@ describe("ExtensionLockComponentService", () => { platformUtilsService.supportsSecureStorage.mockReturnValue( mockInputs.platformSupportsSecureStorage, ); - biometricStateService.biometricUnlockEnabled$ = of(true); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true)); // PIN pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable); + // PRF + webAuthnPrfUnlockService.isPrfUnlockAvailable.mockResolvedValue(false); + const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); expect(unlockOptions).toEqual(expectedOutput); + expect(biometricStateService.biometricUnlockEnabled$).toHaveBeenCalledWith(userId); }); }); }); diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts index 9f137d694a9..1ed9d1ea967 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts @@ -1,6 +1,3 @@ -// FIXME (PM-22628): angular imports are forbidden in background -// eslint-disable-next-line no-restricted-imports -import { inject } from "@angular/core"; import { combineLatest, defer, firstValueFrom, map, Observable } from "rxjs"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; @@ -11,7 +8,11 @@ import { BiometricsStatus, BiometricStateService, } from "@bitwarden/key-management"; -import { LockComponentService, UnlockOptions } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + UnlockOptions, + WebAuthnPrfUnlockService, +} from "@bitwarden/key-management-ui"; import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; import { BrowserApi } from "../../../platform/browser/browser-api"; @@ -21,11 +22,14 @@ import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service"; export class ExtensionLockComponentService implements LockComponentService { - private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction); - private readonly biometricsService = inject(BiometricsService); - private readonly pinService = inject(PinServiceAbstraction); - private readonly routerService = inject(BrowserRouterService); - private readonly biometricStateService = inject(BiometricStateService); + constructor( + private readonly userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private readonly biometricsService: BiometricsService, + private readonly pinService: PinServiceAbstraction, + private readonly biometricStateService: BiometricStateService, + private readonly routerService: BrowserRouterService, + private readonly webAuthnPrfUnlockService: WebAuthnPrfUnlockService, + ) {} getPreviousUrl(): string | null { return this.routerService.getPreviousUrl() ?? null; @@ -65,7 +69,7 @@ export class ExtensionLockComponentService implements LockComponentService { return combineLatest([ // Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to defer(async () => { - if (!(await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$))) { + if (!(await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$(userId)))) { return BiometricsStatus.NotEnabledLocally; } else { // TODO remove after 2025.3 @@ -81,8 +85,12 @@ export class ExtensionLockComponentService implements LockComponentService { }), this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), defer(() => this.pinService.isPinDecryptionAvailable(userId)), + defer(async () => { + const available = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(userId); + return { available }; + }), ]).pipe( - map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable]) => { + map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable, prfUnlockInfo]) => { const unlockOpts: UnlockOptions = { masterPassword: { enabled: userDecryptionOptions?.hasMasterPassword, @@ -94,6 +102,9 @@ export class ExtensionLockComponentService implements LockComponentService { enabled: biometricsStatus === BiometricsStatus.Available, biometricsStatus: biometricsStatus, }, + prf: { + enabled: prfUnlockInfo.available, + }, }; return unlockOpts; }), diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 26add57d1ae..ce5311f848a 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.12.1", + "version": "2026.1.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 64d182ebd3d..9cb77aa3040 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.12.1", + "version": "2026.1.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index cfc39fa18a1..feefd527636 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -560,7 +560,8 @@ export class BrowserApi { * @param event - The event in which to remove the listener from. * @param callback - The callback you want removed from the event. */ - static removeListener unknown>( + // Chrome's Event.removeListener expects callback args as `any[]` to align with its internal event typings. + static removeListener any>( event: chrome.events.Event, callback: T, ) { diff --git a/apps/browser/src/platform/browser/browser-popup-utils.spec.ts b/apps/browser/src/platform/browser/browser-popup-utils.spec.ts index cb04f30b589..89459523843 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.spec.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.spec.ts @@ -140,11 +140,6 @@ describe("BrowserPopupUtils", () => { describe("openPopout", () => { beforeEach(() => { - jest.spyOn(BrowserApi, "getPlatformInfo").mockResolvedValueOnce({ - os: "linux", - arch: "x86-64", - nacl_arch: "x86-64", - }); jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({ id: 1, left: 100, @@ -155,8 +150,6 @@ describe("BrowserPopupUtils", () => { width: PopupWidthOptions.default, }); jest.spyOn(BrowserApi, "createWindow").mockImplementation(); - jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation(); - jest.spyOn(BrowserApi, "getPlatformInfo").mockImplementation(); }); it("creates a window with the default window options", async () => { @@ -274,63 +267,6 @@ describe("BrowserPopupUtils", () => { url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`, }); }); - - it("exits fullscreen and focuses popout window if the current window is fullscreen and platform is mac", async () => { - const url = "popup/index.html"; - jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false); - jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({ - os: "mac", - arch: "x86-64", - nacl_arch: "x86-64", - }); - jest.spyOn(BrowserApi, "getWindow").mockReset().mockResolvedValueOnce({ - id: 1, - left: 100, - top: 100, - focused: false, - alwaysOnTop: false, - incognito: false, - width: PopupWidthOptions.default, - state: "fullscreen", - }); - jest - .spyOn(BrowserApi, "createWindow") - .mockResolvedValueOnce({ id: 2 } as chrome.windows.Window); - - await BrowserPopupUtils.openPopout(url, { senderWindowId: 1 }); - expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(1, { - state: "maximized", - }); - expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(2, { - focused: true, - }); - }); - - it("doesnt exit fullscreen if the platform is not mac", async () => { - const url = "popup/index.html"; - jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false); - jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({ - os: "win", - arch: "x86-64", - nacl_arch: "x86-64", - }); - jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({ - id: 1, - left: 100, - top: 100, - focused: false, - alwaysOnTop: false, - incognito: false, - width: PopupWidthOptions.default, - state: "fullscreen", - }); - - await BrowserPopupUtils.openPopout(url); - - expect(BrowserApi.updateWindowProperties).not.toHaveBeenCalledWith(1, { - state: "maximized", - }); - }); }); describe("openCurrentPagePopout", () => { diff --git a/apps/browser/src/platform/browser/browser-popup-utils.ts b/apps/browser/src/platform/browser/browser-popup-utils.ts index c8dba57e708..7333023d178 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.ts @@ -168,29 +168,8 @@ export default class BrowserPopupUtils { ) { return; } - const platform = await BrowserApi.getPlatformInfo(); - const isMacOS = platform.os === "mac"; - const isFullscreen = senderWindow.state === "fullscreen"; - const isFullscreenAndMacOS = isFullscreen && isMacOS; - //macOS specific handling for improved UX when sender in fullscreen aka green button; - if (isFullscreenAndMacOS) { - await BrowserApi.updateWindowProperties(senderWindow.id, { - state: "maximized", - }); - //wait for macOS animation to finish - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - - const newWindow = await BrowserApi.createWindow(popoutWindowOptions); - - if (isFullscreenAndMacOS) { - await BrowserApi.updateWindowProperties(newWindow.id, { - focused: true, - }); - } - - return newWindow; + return await BrowserApi.createWindow(popoutWindowOptions); } /** diff --git a/apps/browser/src/platform/flags.ts b/apps/browser/src/platform/flags.ts index 2b1040bcd8a..30441d42979 100644 --- a/apps/browser/src/platform/flags.ts +++ b/apps/browser/src/platform/flags.ts @@ -8,12 +8,8 @@ import { import { GroupPolicyEnvironment } from "../admin-console/types/group-policy-environment"; -import { BrowserApi } from "./browser/browser-api"; - // required to avoid linting errors when there are no flags -export type Flags = { - accountSwitching?: boolean; -} & SharedFlags; +export type Flags = SharedFlags; // required to avoid linting errors when there are no flags export type DevFlags = { @@ -31,14 +27,3 @@ export function devFlagEnabled(flag: keyof DevFlags) { export function devFlagValue(flag: keyof DevFlags) { return baseDevFlagValue(flag); } - -/** Helper method to sync flag specifically for account switching, which as platform-based values. - * If this pattern needs to be repeated, it's better handled by increasing complexity of webpack configurations - * Not by expanding these flag getters. - */ -export function enableAccountSwitching(): boolean { - if (BrowserApi.isSafariApi) { - return false; - } - return flagEnabled("accountSwitching"); -} diff --git a/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts b/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts index e5fe95e2018..d53347b9dce 100644 --- a/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts +++ b/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts @@ -15,7 +15,7 @@ export class IpcContentScriptManagerService { } configService - .getFeatureFlag$(FeatureFlag.IpcChannelFramework) + .getFeatureFlag$(FeatureFlag.ContentScriptIpcChannelFramework) .pipe( mergeMap(async (enabled) => { if (!enabled) { diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html index bce2b5033ae..e04d302ea2c 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html @@ -18,11 +18,11 @@ type="button" role="link" > - + > {{ button.label | i18n }} diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts index 26138d57954..5a40b72daff 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts @@ -3,15 +3,15 @@ import { Component, Input } from "@angular/core"; import { RouterModule } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { IconModule, LinkModule } from "@bitwarden/components"; +import { SvgModule, LinkModule } from "@bitwarden/components"; export type NavButton = { label: string; page: string; - icon: Icon; - iconActive: Icon; + icon: BitSvg; + iconActive: BitSvg; showBerry?: boolean; }; @@ -20,7 +20,7 @@ export type NavButton = { @Component({ selector: "popup-tab-navigation", templateUrl: "popup-tab-navigation.component.html", - imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule], + imports: [CommonModule, LinkModule, RouterModule, JslibModule, SvgModule], host: { class: "tw-block tw-size-full tw-flex tw-flex-col", }, diff --git a/apps/browser/src/platform/services/browser-local-storage.service.ts b/apps/browser/src/platform/services/browser-local-storage.service.ts index 30454cf6a77..e34c3ac4904 100644 --- a/apps/browser/src/platform/services/browser-local-storage.service.ts +++ b/apps/browser/src/platform/services/browser-local-storage.service.ts @@ -15,6 +15,22 @@ export default class BrowserLocalStorageService extends AbstractChromeStorageSer return await this.getWithRetries(key, 0); } + /** + * Retrieves all storage keys. + * + * Returns all keys stored in local storage when the browser supports the getKeys API (Chrome 130+). + * Returns an empty array on older browser versions where this feature is unavailable. + * + * @returns Array of storage keys, or empty array if the feature is not supported + */ + async getKeys(): Promise { + // getKeys function is only available since Chrome 130 + if ("getKeys" in this.chromeStorageApi) { + return this.chromeStorageApi.getKeys(); + } + return []; + } + private async getWithRetries(key: string, retryNum: number): Promise { // See: https://github.com/EFForg/privacybadger/pull/2980 const MAX_RETRIES = 5; diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts index 947fecb5aac..26b51e2fea7 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts @@ -1,20 +1,89 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { Lazy } from "@bitwarden/common/platform/misc/lazy"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { FakeStorageService, makeEncString } from "@bitwarden/common/spec"; +import { FakeStorageService, makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec"; +import { StorageService } from "@bitwarden/storage-core"; -import { LocalBackedSessionStorageService } from "./local-backed-session-storage.service"; +import BrowserLocalStorageService from "./browser-local-storage.service"; +import { + LocalBackedSessionStorageService, + SessionKeyResolveService, +} from "./local-backed-session-storage.service"; + +describe("SessionKeyResolveService", () => { + let storageService: FakeStorageService; + let keyGenerationService: MockProxy; + let sut: SessionKeyResolveService; + + const mockKey = makeSymmetricCryptoKey(); + + beforeEach(() => { + storageService = new FakeStorageService(); + keyGenerationService = mock(); + sut = new SessionKeyResolveService(storageService, keyGenerationService); + }); + + describe("get", () => { + it("returns null when no session key exists", async () => { + const result = await sut.get(); + expect(result).toBeNull(); + }); + + it("returns the session key from storage", async () => { + await storageService.save("session-key", mockKey); + const result = await sut.get(); + expect(result).toEqual(mockKey); + }); + + it("deserializes the session key when storage requires deserialization", async () => { + const mockStorageService = mock(); + Object.defineProperty(mockStorageService, "valuesRequireDeserialization", { + get: () => true, + }); + mockStorageService.get.mockResolvedValue(mockKey.toJSON()); + + const deserializableSut = new SessionKeyResolveService( + mockStorageService, + keyGenerationService, + ); + + const result = await deserializableSut.get(); + + expect(result).toBeInstanceOf(SymmetricCryptoKey); + expect(result?.toJSON()).toEqual(mockKey.toJSON()); + }); + }); + + describe("create", () => { + it("creates a new session key and saves it to storage", async () => { + keyGenerationService.createKeyWithPurpose.mockResolvedValue({ + salt: "salt", + material: new Uint8Array(16) as any, + derivedKey: mockKey, + }); + + const result = await sut.create(); + + expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalledWith( + 128, + "ephemeral", + "bitwarden-ephemeral", + ); + expect(result).toEqual(mockKey); + expect(await storageService.get("session-key")).toEqual(mockKey.toJSON()); + }); + }); +}); describe("LocalBackedSessionStorage", () => { - const sessionKey = new SymmetricCryptoKey( - Utils.fromUtf8ToArray("00000000000000000000000000000000"), - ); - let localStorage: FakeStorageService; + const sessionKey = makeSymmetricCryptoKey(); + let memoryStorage: MockProxy; + let keyGenerationService: MockProxy; + let localStorage: MockProxy; let encryptService: MockProxy; let platformUtilsService: MockProxy; let logService: MockProxy; @@ -22,14 +91,23 @@ describe("LocalBackedSessionStorage", () => { let sut: LocalBackedSessionStorageService; beforeEach(() => { - localStorage = new FakeStorageService(); + memoryStorage = mock(); + keyGenerationService = mock(); + localStorage = mock(); encryptService = mock(); platformUtilsService = mock(); logService = mock(); + // Default: session key exists + memoryStorage.get.mockResolvedValue(sessionKey); + Object.defineProperty(memoryStorage, "valuesRequireDeserialization", { + get: () => true, + }); + sut = new LocalBackedSessionStorageService( - new Lazy(async () => sessionKey), + memoryStorage, localStorage, + keyGenerationService, encryptService, platformUtilsService, logService, @@ -37,57 +115,79 @@ describe("LocalBackedSessionStorage", () => { }); describe("get", () => { - it("return the cached value when one is cached", async () => { + const encString = makeEncString("encrypted"); + + it("returns the cached value when one is cached", async () => { sut["cache"]["test"] = "cached"; const result = await sut.get("test"); expect(result).toEqual("cached"); }); - it("returns a decrypted value when one is stored in local storage", async () => { - const encrypted = makeEncString("encrypted"); - localStorage.internalStore["session_test"] = encrypted.encryptedString; - encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); - const result = await sut.get("test"); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - (expect(encryptService.decryptString).toHaveBeenCalledWith(encrypted, sessionKey), - expect(result).toEqual("decrypted")); - }); + it("returns null when both cache and storage are null", async () => { + sut["cache"]["test"] = null; + localStorage.get.mockResolvedValue(null); - it("caches the decrypted value when one is stored in local storage", async () => { - const encrypted = makeEncString("encrypted"); - localStorage.internalStore["session_test"] = encrypted.encryptedString; - encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); - await sut.get("test"); - expect(sut["cache"]["test"]).toEqual("decrypted"); + const result = await sut.get("test"); + + expect(result).toBeNull(); + expect(localStorage.get).toHaveBeenCalledWith("session_test"); }); it("returns a decrypted value when one is stored in local storage", async () => { - const encrypted = makeEncString("encrypted"); - localStorage.internalStore["session_test"] = encrypted.encryptedString; + localStorage.get.mockResolvedValue(encString.encryptedString); encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); + const result = await sut.get("test"); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - (expect(encryptService.decryptString).toHaveBeenCalledWith(encrypted, sessionKey), - expect(result).toEqual("decrypted")); + + expect(encryptService.decryptString).toHaveBeenCalledWith(encString, sessionKey); + expect(result).toEqual("decrypted"); + expect(sut["cache"]["test"]).toEqual("decrypted"); }); - it("caches the decrypted value when one is stored in local storage", async () => { - const encrypted = makeEncString("encrypted"); - localStorage.internalStore["session_test"] = encrypted.encryptedString; - encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); - await sut.get("test"); - expect(sut["cache"]["test"]).toEqual("decrypted"); + it("returns the cached value when cache is populated during storage retrieval", async () => { + localStorage.get.mockImplementation(async () => { + sut["cache"]["test"] = "cached-during-read"; + return encString.encryptedString; + }); + encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted-from-storage")); + + const result = await sut.get("test"); + + expect(result).toEqual("cached-during-read"); + }); + + it("returns the cached value when storage returns null but cache was filled", async () => { + localStorage.get.mockImplementation(async () => { + sut["cache"]["test"] = "cached-during-read"; + return null; + }); + + const result = await sut.get("test"); + + expect(result).toEqual("cached-during-read"); + }); + + it("creates new session key, clears old data, and returns null when session key is missing", async () => { + const newSessionKey = makeSymmetricCryptoKey(); + const clearSpy = jest.spyOn(sut as any, "clear"); + memoryStorage.get.mockResolvedValue(null); + keyGenerationService.createKeyWithPurpose.mockResolvedValue({ + salt: "salt", + material: new Uint8Array(16) as any, + derivedKey: newSessionKey, + }); + localStorage.get.mockResolvedValue(null); + localStorage.getKeys.mockResolvedValue([]); + + const result = await sut.get("test"); + + expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalled(); + expect(clearSpy).toHaveBeenCalled(); + expect(result).toBeNull(); }); }); describe("has", () => { - it("returns false when the key is not in cache", async () => { - const result = await sut.has("test"); - expect(result).toBe(false); - }); - it("returns true when the key is in cache", async () => { sut["cache"]["test"] = "cached"; const result = await sut.has("test"); @@ -95,21 +195,17 @@ describe("LocalBackedSessionStorage", () => { }); it("returns true when the key is in local storage", async () => { - localStorage.internalStore["session_test"] = makeEncString("encrypted").encryptedString; + const encString = makeEncString("encrypted"); + localStorage.get.mockResolvedValue(encString.encryptedString); encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); const result = await sut.has("test"); expect(result).toBe(true); }); - it.each([null, undefined])("returns false when %s is cached", async (nullish) => { - sut["cache"]["test"] = nullish; - await expect(sut.has("test")).resolves.toBe(false); - }); - it.each([null, undefined])( - "returns false when null is stored in local storage", - async (nullish) => { - localStorage.internalStore["session_test"] = nullish; + "returns false when the key does not exist in local storage (%s)", + async (value) => { + localStorage.get.mockResolvedValue(value); await expect(sut.has("test")).resolves.toBe(false); expect(encryptService.decryptString).not.toHaveBeenCalled(); }, @@ -118,6 +214,7 @@ describe("LocalBackedSessionStorage", () => { describe("save", () => { const encString = makeEncString("encrypted"); + beforeEach(() => { encryptService.encryptString.mockResolvedValue(encString); }); @@ -137,29 +234,44 @@ describe("LocalBackedSessionStorage", () => { }); it("removes the key when saving a null value", async () => { - const spy = jest.spyOn(sut, "remove"); + const removeSpy = jest.spyOn(sut, "remove"); await sut.save("test", null); - expect(spy).toHaveBeenCalledWith("test"); + expect(removeSpy).toHaveBeenCalledWith("test"); }); - it("saves the value to cache", async () => { + it("uses the session key when encrypting", async () => { await sut.save("test", "value"); - expect(sut["cache"]["test"]).toEqual("value"); - }); - it("encrypts and saves the value to local storage", async () => { - await sut.save("test", "value"); + expect(memoryStorage.get).toHaveBeenCalledWith("session-key"); expect(encryptService.encryptString).toHaveBeenCalledWith( JSON.stringify("value"), sessionKey, ); - expect(localStorage.internalStore["session_test"]).toEqual(encString.encryptedString); }); it("emits an update", async () => { - const spy = jest.spyOn(sut["updatesSubject"], "next"); + const updateSpy = jest.spyOn(sut["updatesSubject"], "next"); await sut.save("test", "value"); - expect(spy).toHaveBeenCalledWith({ key: "test", updateType: "save" }); + expect(updateSpy).toHaveBeenCalledWith({ key: "test", updateType: "save" }); + }); + + it("creates a new session key when session key is missing before saving", async () => { + const newSessionKey = makeSymmetricCryptoKey(); + memoryStorage.get.mockResolvedValue(null); + keyGenerationService.createKeyWithPurpose.mockResolvedValue({ + salt: "salt", + material: new Uint8Array(16) as any, + derivedKey: newSessionKey, + }); + localStorage.getKeys.mockResolvedValue([]); + + await sut.save("test", "value"); + + expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalled(); + expect(encryptService.encryptString).toHaveBeenCalledWith( + JSON.stringify("value"), + newSessionKey, + ); }); }); @@ -171,15 +283,50 @@ describe("LocalBackedSessionStorage", () => { }); it("removes the key from local storage", async () => { - localStorage.internalStore["session_test"] = makeEncString("encrypted").encryptedString; await sut.remove("test"); - expect(localStorage.internalStore["session_test"]).toBeUndefined(); + expect(localStorage.remove).toHaveBeenCalledWith("session_test"); }); it("emits an update", async () => { - const spy = jest.spyOn(sut["updatesSubject"], "next"); + const updateSpy = jest.spyOn(sut["updatesSubject"], "next"); await sut.remove("test"); - expect(spy).toHaveBeenCalledWith({ key: "test", updateType: "remove" }); + expect(updateSpy).toHaveBeenCalledWith({ key: "test", updateType: "remove" }); + }); + }); + + describe("sessionStorageKey", () => { + it("prefixes keys with session_ prefix", () => { + expect(sut["sessionStorageKey"]("test")).toBe("session_test"); + }); + }); + + describe("clear", () => { + it("only removes keys with session_ prefix", async () => { + const removeSpy = jest.spyOn(sut, "remove"); + localStorage.getKeys.mockResolvedValue([ + "session_data1", + "session_data2", + "regular_key", + "another_key", + "session_data3", + "my_session_key", + "mysession", + "sessiondata", + "user_session", + ]); + + await sut["clear"](); + + expect(removeSpy).toHaveBeenCalledWith("data1"); + expect(removeSpy).toHaveBeenCalledWith("data2"); + expect(removeSpy).toHaveBeenCalledWith("data3"); + expect(removeSpy).not.toHaveBeenCalledWith("regular_key"); + expect(removeSpy).not.toHaveBeenCalledWith("another_key"); + expect(removeSpy).not.toHaveBeenCalledWith("my_session_key"); + expect(removeSpy).not.toHaveBeenCalledWith("mysession"); + expect(removeSpy).not.toHaveBeenCalledWith("sessiondata"); + expect(removeSpy).not.toHaveBeenCalledWith("user_session"); + expect(removeSpy).toHaveBeenCalledTimes(3); }); }); }); diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.ts index d0613ee644c..63a51d28e35 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Subject } from "rxjs"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -12,33 +13,94 @@ import { StorageUpdate, } from "@bitwarden/common/platform/abstractions/storage.service"; import { compareValues } from "@bitwarden/common/platform/misc/compare-values"; -import { Lazy } from "@bitwarden/common/platform/misc/lazy"; import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { StorageService } from "@bitwarden/storage-core"; import { BrowserApi } from "../browser/browser-api"; import { MemoryStoragePortMessage } from "../storage/port-messages"; import { portName } from "../storage/port-name"; +import BrowserLocalStorageService from "./browser-local-storage.service"; + +const SESSION_KEY_PREFIX = "session_"; + +/** + * Manages an ephemeral session key for encrypting session storage items persisted in local storage. + * + * The session key is stored in session storage and automatically cleared when the browser session ends + * (e.g., browser restart, extension reload). When the session key is unavailable, any encrypted items + * in local storage cannot be decrypted and must be cleared to maintain data consistency. + * + * This provides session-scoped security for sensitive data while using persistent local storage as the backing store. + * + * @internal Internal implementation detail. Exported only for testing purposes. + * Do not use this class directly outside of tests. Use LocalBackedSessionStorageService instead. + */ +export class SessionKeyResolveService { + constructor( + private readonly storageService: StorageService, + private readonly keyGenerationService: KeyGenerationService, + ) {} + + /** + * Retrieves the session key from storage. + * + * @return session key or null when not in storage + */ + async get(): Promise { + const key = await this.storageService.get("session-key"); + if (key) { + if (this.storageService.valuesRequireDeserialization) { + return SymmetricCryptoKey.fromJSON(key); + } + return key; + } + return null; + } + + /** + * Creates new session key and adds it to underlying storage. + * + * @return newly created session key + */ + async create(): Promise { + const { derivedKey } = await this.keyGenerationService.createKeyWithPurpose( + 128, + "ephemeral", + "bitwarden-ephemeral", + ); + await this.storageService.save("session-key", derivedKey.toJSON()); + return derivedKey; + } +} + export class LocalBackedSessionStorageService extends AbstractStorageService implements ObservableStorageService { + readonly valuesRequireDeserialization = true; private ports: Set = new Set([]); private cache: Record = {}; private updatesSubject = new Subject(); - readonly valuesRequireDeserialization = true; updates$ = this.updatesSubject.asObservable(); + private readonly sessionKeyResolveService: SessionKeyResolveService; constructor( - private readonly sessionKey: Lazy>, - private readonly localStorage: AbstractStorageService, + private readonly memoryStorage: StorageService, + private readonly localStorage: BrowserLocalStorageService, + private readonly keyGenerationService: KeyGenerationService, private readonly encryptService: EncryptService, private readonly platformUtilsService: PlatformUtilsService, private readonly logService: LogService, ) { super(); + this.sessionKeyResolveService = new SessionKeyResolveService( + this.memoryStorage, + this.keyGenerationService, + ); + BrowserApi.addListener(chrome.runtime.onConnect, (port) => { if (port.name !== portName(chrome.storage.session)) { return; @@ -70,20 +132,20 @@ export class LocalBackedSessionStorageService } async get(key: string, options?: StorageOptions): Promise { - if (this.cache[key] !== undefined) { + if (this.cache[key] != null) { return this.cache[key] as T; } - const value = await this.getLocalSessionValue(await this.sessionKey.get(), key); + const value = await this.getLocalSessionValue(await this.getSessionKey(), key); - if (this.cache[key] === undefined && value !== undefined) { + if (this.cache[key] == null && value != null) { // Cache is still empty and we just got a value from local/session storage, cache it. this.cache[key] = value; return value as T; - } else if (this.cache[key] === undefined && value === undefined) { + } else if (this.cache[key] == null && value == null) { // Cache is still empty and we got nothing from local/session storage, no need to modify cache. return value as T; - } else if (this.cache[key] !== undefined && value !== undefined) { + } else if (this.cache[key] != null && value != null) { // Conflict, somebody wrote to the cache while we were reading from storage // but we also got a value from storage. We assume the cache is more up to date // and use that value. @@ -91,7 +153,7 @@ export class LocalBackedSessionStorageService `Conflict while reading from local session storage, both cache and storage have values. Key: ${key}. Using cached value.`, ); return this.cache[key] as T; - } else if (this.cache[key] !== undefined && value === undefined) { + } else if (this.cache[key] != null && value == null) { // Cache was filled after the local/session storage read completed. We got null // from the storage read, but we have a value from the cache, use that. this.logService.warning( @@ -136,6 +198,44 @@ export class LocalBackedSessionStorageService this.updatesSubject.next({ key, updateType: "remove" }); } + protected broadcastMessage(data: Omit) { + this.ports.forEach((port) => { + this.sendMessageTo(port, data); + }); + } + + private async getSessionKey(): Promise { + const sessionKey = await this.sessionKeyResolveService.get(); + if (sessionKey != null) { + return sessionKey; + } + + // Session key is missing (browser restart/extension reload), so all stored session data + // cannot be decrypted. Clear all items before creating a new session key. + await this.clear(); + + return await this.sessionKeyResolveService.create(); + } + + /** + * Removes all stored session data. + * + * Called when the session key is unavailable (typically after browser restart or extension reload), + * making all encrypted session data unrecoverable. Prevents orphaned encrypted data from accumulating. + */ + private async clear() { + const keys = (await this.localStorage.getKeys()).filter((key) => + key.startsWith(SESSION_KEY_PREFIX), + ); + this.logService.debug( + `[LocalBackedSessionStorageService] Clearing local session storage. Found ${keys}`, + ); + for (const key of keys) { + const keyWithoutPrefix = key.substring(SESSION_KEY_PREFIX.length); + await this.remove(keyWithoutPrefix); + } + } + private async getLocalSessionValue(encKey: SymmetricCryptoKey, key: string): Promise { const local = await this.localStorage.get(this.sessionStorageKey(key)); if (local == null) { @@ -159,10 +259,7 @@ export class LocalBackedSessionStorageService } const valueJson = JSON.stringify(value); - const encValue = await this.encryptService.encryptString( - valueJson, - await this.sessionKey.get(), - ); + const encValue = await this.encryptService.encryptString(valueJson, await this.getSessionKey()); await this.localStorage.save(this.sessionStorageKey(key), encValue.encryptedString); } @@ -197,12 +294,6 @@ export class LocalBackedSessionStorageService }); } - protected broadcastMessage(data: Omit) { - this.ports.forEach((port) => { - this.sendMessageTo(port, data); - }); - } - private sendMessageTo( port: chrome.runtime.Port, data: Omit, @@ -214,7 +305,7 @@ export class LocalBackedSessionStorageService } private sessionStorageKey(key: string) { - return `session_${key}`; + return `${SESSION_KEY_PREFIX}${key}`; } private compareValues(value1: T, value2: T): boolean { diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html index 484f9680519..2cf1998bb05 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -6,7 +6,7 @@ [pageTitle]="''" >
- +
diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts index 3a50f03e982..e07e9c50554 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts @@ -5,10 +5,10 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; import { Subject, filter, switchMap, takeUntil, tap } from "rxjs"; -import { BitwardenLogo, Icon } from "@bitwarden/assets/svg"; +import { BitwardenLogo, BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { - IconModule, + SvgModule, Translation, AnonLayoutComponent, AnonLayoutWrapperData, @@ -38,7 +38,7 @@ export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData { CommonModule, CurrentAccountComponent, I18nPipe, - IconModule, + SvgModule, PopOutComponent, PopupPageComponent, PopupHeaderComponent, @@ -54,7 +54,7 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { protected pageTitle: string; protected pageSubtitle: string; - protected pageIcon: Icon; + protected pageIcon: BitSvg; protected showReadonlyHostname: boolean; protected maxWidth: "md" | "3xl"; protected hasLoggedInAccount: boolean = false; diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7b207f0fac1..a8bfb23d83f 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -54,6 +54,7 @@ import { } from "@bitwarden/auto-confirm"; import { ExtensionAuthRequestAnsweringService } from "@bitwarden/browser/auth/services/auth-request-answering/extension-auth-request-answering.service"; import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service"; +import { BrowserRouterService } from "@bitwarden/browser/platform/popup/services/browser-router.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { @@ -71,6 +72,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; import { AutofillSettingsService, @@ -96,6 +98,7 @@ import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeoutService, @@ -160,12 +163,15 @@ import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { BiometricsService, + BiometricStateService, DefaultKeyService, KdfConfigService, KeyService, } from "@bitwarden/key-management"; import { LockComponentService, + WebAuthnPrfUnlockService, + DefaultWebAuthnPrfUnlockService, SessionTimeoutSettingsComponentService, } from "@bitwarden/key-management-ui"; import { DerivedStateProvider, GlobalStateProvider, StateProvider } from "@bitwarden/state"; @@ -572,15 +578,6 @@ const safeProviders: SafeProvider[] = [ useFactory: () => new Subject>>(), deps: [], }), - safeProvider({ - provide: MessageSender, - useFactory: (subject: Subject>>, logService: LogService) => - MessageSender.combine( - new SubjectMessageSender(subject), // For sending messages in the same context - new ChromeMessageSender(logService), // For sending messages to different contexts - ), - deps: [INTRAPROCESS_MESSAGING_SUBJECT, LogService], - }), safeProvider({ provide: DISK_BACKUP_LOCAL_STORAGE, useFactory: (diskStorage: AbstractStorageService & ObservableStorageService) => @@ -604,7 +601,14 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: LockComponentService, useClass: ExtensionLockComponentService, - deps: [], + deps: [ + UserDecryptionOptionsServiceAbstraction, + BiometricsService, + PinServiceAbstraction, + BiometricStateService, + BrowserRouterService, + WebAuthnPrfUnlockService, + ], }), // TODO: PM-18182 - Refactor component services into lazy loaded modules safeProvider({ @@ -653,6 +657,21 @@ const safeProviders: SafeProvider[] = [ AccountServiceAbstraction, ], }), + safeProvider({ + provide: WebAuthnPrfUnlockService, + useClass: DefaultWebAuthnPrfUnlockService, + deps: [ + WebAuthnLoginPrfKeyServiceAbstraction, + KeyService, + UserDecryptionOptionsServiceAbstraction, + EncryptService, + EnvironmentService, + PlatformUtilsService, + WINDOW, + LogService, + ConfigService, + ], + }), safeProvider({ provide: AnimationControlService, useClass: DefaultAnimationControlService, diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html index 828c1667c57..94c1df46eea 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html @@ -14,7 +14,7 @@ class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-center tw-items-center tw-gap-2 tw-h-full tw-px-5" >
- +

{{ "createdSendSuccessfully" | i18n }} 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 521d72bba0c..a19897b6bbc 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 @@ -14,7 +14,7 @@ import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/defau 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 { ButtonModule, I18nMockService, SvgModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; @@ -76,7 +76,7 @@ describe("SendCreatedComponent", () => { RouterTestingModule, JslibModule, ButtonModule, - IconModule, + SvgModule, PopOutComponent, PopupHeaderComponent, PopupPageComponent, diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts index e9109ec6c21..e3717075e24 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts @@ -13,7 +13,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; -import { ButtonModule, IconModule, ToastService } from "@bitwarden/components"; +import { ButtonModule, SvgModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; @@ -34,7 +34,7 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page PopupPageComponent, RouterModule, PopupFooterComponent, - IconModule, + SvgModule, ], }) export class SendCreatedComponent { diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.html b/apps/browser/src/tools/popup/send-v2/send-v2.component.html index 47ecd7564dc..48295fda35d 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.html @@ -1,4 +1,4 @@ - + diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts index dfbfabf8d5e..dc4b935c6c8 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -11,7 +11,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -110,7 +109,6 @@ describe("SendV2Component", () => { provide: BillingAccountProfileStateService, useValue: { hasPremiumFromAnySource$: of(false) }, }, - { provide: ConfigService, useValue: mock() }, { provide: EnvironmentService, useValue: mock() }, { provide: LogService, useValue: mock() }, { provide: PlatformUtilsService, useValue: mock() }, diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index f36a475a805..8c1edee79dc 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -11,8 +11,6 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; @@ -84,30 +82,17 @@ export class SendV2Component implements OnDestroy { protected listState: SendState | null = null; protected sends$ = this.sendItemsService.filteredAndSortedSends$; - private skeletonFeatureFlag$ = this.configService.getFeatureFlag$( - FeatureFlag.VaultLoadingSkeletons, - ); protected sendsLoading$ = this.sendItemsService.loading$.pipe( distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }), ); - /** Spinner Loading State */ - protected showSpinnerLoaders$ = combineLatest([ - this.sendsLoading$, - this.skeletonFeatureFlag$, - ]).pipe(map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled)); - /** Skeleton Loading State */ protected showSkeletonsLoaders$ = combineLatest([ this.sendsLoading$, this.searchService.isSendSearching$, - this.skeletonFeatureFlag$, ]).pipe( - map( - ([loading, cipherSearching, skeletonsEnabled]) => - (loading || cipherSearching) && skeletonsEnabled, - ), + map(([loading, cipherSearching]) => loading || cipherSearching), distinctUntilChanged(), skeletonLoadingDelay(), ); @@ -128,7 +113,6 @@ export class SendV2Component implements OnDestroy { protected sendListFiltersService: SendListFiltersService, private policyService: PolicyService, private accountService: AccountService, - private configService: ConfigService, private searchService: SearchService, ) { combineLatest([ diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.html b/apps/browser/src/tools/popup/settings/settings-v2.component.html index c6f1c9dbc3b..19f2445b61d 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -110,7 +110,7 @@ -
+

{{ "downloadBitwardenOnAllDevices" | i18n }}

{ }); it("passes the submit button to the cipher attachments component", () => { - const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1] + const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[0] .componentInstance; expect(cipherAttachment.submitBtn()).toEqual(submitBtn); diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html index 5f19092d6b0..1980e8aa356 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html @@ -2,7 +2,7 @@
- +

{{ "securityPrioritized" | i18n }}

{{ "securityPrioritizedBody" | i18n }}

@@ -11,7 +11,7 @@
- +

{{ "quickLogin" | i18n }}

{{ "quickLoginBody" | i18n }}

@@ -20,7 +20,7 @@
- +

{{ "secureUser" | i18n }}

{{ "secureUserBody" | i18n }}

@@ -29,7 +29,7 @@
- +

{{ "secureDevices" | i18n }}

{{ "secureDevicesBody" | i18n }}

diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts index 48c8f5682bc..5ad44c2f545 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts @@ -3,7 +3,7 @@ import { Router } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ItemTypes, LoginCards, NoCredentialsIcon, DevicesIcon } from "@bitwarden/assets/svg"; -import { ButtonModule, DialogModule, IconModule, TypographyModule } from "@bitwarden/components"; +import { ButtonModule, DialogModule, SvgModule, TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { VaultCarouselModule } from "@bitwarden/vault"; @@ -17,7 +17,7 @@ import { IntroCarouselService } from "../../../services/intro-carousel.service"; imports: [ VaultCarouselModule, ButtonModule, - IconModule, + SvgModule, DialogModule, TypographyModule, JslibModule, 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 f881b07282b..d7de51ad20f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -277,8 +277,7 @@ export class ItemMoreOptionsComponent { this.accountService.activeAccount$.pipe(map((a) => a?.id)), )) as UserId; - const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId); - await this.cipherService.updateWithServer(encryptedCipher); + await this.cipherService.updateWithServer(cipher, activeUserId); this.toastService.showToast({ variant: "success", message: this.i18nService.t( diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts index 37c4804e600..ca73a7332ee 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts @@ -4,7 +4,6 @@ import { FormsModule } from "@angular/forms"; import { BehaviorSubject } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; import { SearchModule } from "@bitwarden/components"; @@ -20,7 +19,6 @@ describe("VaultV2SearchComponent", () => { const searchText$ = new BehaviorSubject(""); const loading$ = new BehaviorSubject(false); - const featureFlag$ = new BehaviorSubject(true); const applyFilter = jest.fn(); const createComponent = () => { @@ -31,7 +29,6 @@ describe("VaultV2SearchComponent", () => { beforeEach(async () => { applyFilter.mockClear(); - featureFlag$.next(true); await TestBed.configureTestingModule({ imports: [VaultV2SearchComponent, CommonModule, SearchModule, JslibModule, FormsModule], @@ -49,12 +46,6 @@ describe("VaultV2SearchComponent", () => { loading$, }, }, - { - provide: ConfigService, - useValue: { - getFeatureFlag$: jest.fn(() => featureFlag$), - }, - }, { provide: I18nService, useValue: { t: (key: string) => key } }, ], }).compileComponents(); @@ -70,91 +61,55 @@ describe("VaultV2SearchComponent", () => { }); describe("debouncing behavior", () => { - describe("when feature flag is enabled", () => { - beforeEach(() => { - featureFlag$.next(true); - createComponent(); - }); - - it("debounces search text changes when not loading", fakeAsync(() => { - loading$.next(false); - - component.searchText = "test"; - component.onSearchTextChanged(); - - expect(applyFilter).not.toHaveBeenCalled(); - - tick(SearchTextDebounceInterval); - - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - - it("should not debounce search text changes when loading", fakeAsync(() => { - loading$.next(true); - - component.searchText = "test"; - component.onSearchTextChanged(); - - tick(0); - - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - - it("cancels previous debounce when new text is entered", fakeAsync(() => { - loading$.next(false); - - component.searchText = "test"; - component.onSearchTextChanged(); - - tick(SearchTextDebounceInterval / 2); - - component.searchText = "test2"; - component.onSearchTextChanged(); - - tick(SearchTextDebounceInterval / 2); - - expect(applyFilter).not.toHaveBeenCalled(); - - tick(SearchTextDebounceInterval / 2); - - expect(applyFilter).toHaveBeenCalledWith("test2"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); + beforeEach(() => { + createComponent(); }); - describe("when feature flag is disabled", () => { - beforeEach(() => { - featureFlag$.next(false); - createComponent(); - }); + it("debounces search text changes when not loading", fakeAsync(() => { + loading$.next(false); - it("debounces search text changes", fakeAsync(() => { - component.searchText = "test"; - component.onSearchTextChanged(); + component.searchText = "test"; + component.onSearchTextChanged(); - expect(applyFilter).not.toHaveBeenCalled(); + expect(applyFilter).not.toHaveBeenCalled(); - tick(SearchTextDebounceInterval); + tick(SearchTextDebounceInterval); - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); - it("ignores loading state and always debounces", fakeAsync(() => { - loading$.next(true); + it("should not debounce search text changes when loading", fakeAsync(() => { + loading$.next(true); - component.searchText = "test"; - component.onSearchTextChanged(); + component.searchText = "test"; + component.onSearchTextChanged(); - expect(applyFilter).not.toHaveBeenCalled(); + tick(0); - tick(SearchTextDebounceInterval); + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - }); + it("cancels previous debounce when new text is entered", fakeAsync(() => { + loading$.next(false); + + component.searchText = "test"; + component.onSearchTextChanged(); + + tick(SearchTextDebounceInterval / 2); + + component.searchText = "test2"; + component.onSearchTextChanged(); + + tick(SearchTextDebounceInterval / 2); + + expect(applyFilter).not.toHaveBeenCalled(); + + tick(SearchTextDebounceInterval / 2); + + expect(applyFilter).toHaveBeenCalledWith("test2"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts index 154cd49c5a3..3419bd30ea0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts @@ -7,17 +7,13 @@ import { Subscription, combineLatest, debounce, - debounceTime, distinctUntilChanged, filter, map, - switchMap, timer, } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; import { SearchModule } from "@bitwarden/components"; @@ -40,7 +36,6 @@ export class VaultV2SearchComponent { constructor( private vaultPopupItemsService: VaultPopupItemsService, private vaultPopupLoadingService: VaultPopupLoadingService, - private configService: ConfigService, private ngZone: NgZone, ) { this.subscribeToLatestSearchText(); @@ -63,31 +58,19 @@ export class VaultV2SearchComponent { } subscribeToApplyFilter(): void { - this.configService - .getFeatureFlag$(FeatureFlag.VaultLoadingSkeletons) + combineLatest([this.searchText$, this.loading$]) .pipe( - switchMap((enabled) => { - if (!enabled) { - return this.searchText$.pipe( - debounceTime(SearchTextDebounceInterval), - distinctUntilChanged(), - ); - } - - return combineLatest([this.searchText$, this.loading$]).pipe( - debounce(([_, isLoading]) => { - // If loading apply immediately to avoid stale searches. - // After loading completes, debounce to avoid excessive searches. - const delayTime = isLoading ? 0 : SearchTextDebounceInterval; - return timer(delayTime); - }), - distinctUntilChanged( - ([prevText, prevLoading], [newText, newLoading]) => - prevText === newText && prevLoading === newLoading, - ), - map(([text, _]) => text), - ); + debounce(([_, isLoading]) => { + // If loading apply immediately to avoid stale searches. + // After loading completes, debounce to avoid excessive searches. + const delayTime = isLoading ? 0 : SearchTextDebounceInterval; + return timer(delayTime); }), + distinctUntilChanged( + ([prevText, prevLoading], [newText, newLoading]) => + prevText === newText && prevLoading === newLoading, + ), + map(([text, _]) => text), takeUntilDestroyed(), ) .subscribe((text) => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index 34454371f21..20871b4b134 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -1,4 +1,4 @@ - + @@ -8,37 +8,28 @@ - -
- - - @if (skeletonFeatureFlag$ | async) { - - + @if (vaultState === VaultStateEnum.Empty) { + +
+ + {{ "yourVaultIsEmpty" | i18n }} + +

+ {{ "emptyVaultDescription" | i18n }} +

+
+ + {{ "newLogin" | i18n }} + +
+
- } @else { - }
- - - - - - - - - @if (skeletonFeatureFlag$ | async) { - - + @if (vaultState === null) { + + @if (!(loading$ | async)) { + + + + } - } @else { - } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index 2c94d9c226b..d7824f3df58 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, input, NO_ERRORS_SCHEMA } from "@angular/core"; import { TestBed, fakeAsync, flush, tick } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; import { ActivatedRoute, Router } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; import { mock } from "jest-mock-extended"; @@ -8,7 +9,6 @@ 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, @@ -184,7 +184,7 @@ describe("VaultV2Component", () => { filterVisibilityState$: new BehaviorSubject({}), } as Partial; - const accountActive$ = new BehaviorSubject({ id: "user-1" }); + const activeAccount$ = new BehaviorSubject({ id: "user-1" }); const cipherSvc = { failedToDecryptCiphers$: jest.fn().mockReturnValue(of([])), @@ -221,12 +221,6 @@ describe("VaultV2Component", () => { hasPremiumFromAnySource$: (_: string) => hasPremiumFromAnySource$, }; - const vaultProfileSvc = { - getProfileCreationDate: jest - .fn() - .mockResolvedValue(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)), // 8 days ago - }; - const configSvc = { getFeatureFlag$: jest.fn().mockImplementation((_flag: string) => of(false)), }; @@ -243,21 +237,18 @@ describe("VaultV2Component", () => { await TestBed.configureTestingModule({ imports: [VaultV2Component, RouterTestingModule], providers: [ + provideNoopAnimations(), { provide: VaultPopupItemsService, useValue: itemsSvc }, { provide: VaultPopupListFiltersService, useValue: filtersSvc }, { provide: VaultPopupScrollPositionService, useValue: scrollSvc }, { provide: AccountService, - useValue: { activeAccount$: accountActive$ }, + useValue: { activeAccount$ }, }, { provide: CipherService, useValue: cipherSvc }, { provide: DialogService, useValue: dialogSvc }, { provide: IntroCarouselService, useValue: introSvc }, { provide: NudgesService, useValue: nudgesSvc }, - { - provide: VaultProfileService, - useValue: vaultProfileSvc, - }, { provide: VaultPopupCopyButtonsService, useValue: { showQuickCopyActions$: new BehaviorSubject(false) }, @@ -471,7 +462,7 @@ describe("VaultV2Component", () => { it("dismissVaultNudgeSpotlight forwards to NudgesService with active user id", fakeAsync(() => { const spy = jest.spyOn(nudgesSvc, "dismissNudge").mockResolvedValue(undefined); - accountActive$.next({ id: "user-xyz" }); + activeAccount$.next({ id: "user-xyz" }); void component.ngOnInit(); tick(); @@ -483,6 +474,10 @@ describe("VaultV2Component", () => { })); it("accountAgeInDays$ computes integer days since creation", (done) => { + activeAccount$.next({ + id: "user-123", + creationDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days ago + } as any); getObs(component, "accountAgeInDays$").subscribe((days) => { if (days !== null) { expect(days).toBeGreaterThanOrEqual(7); @@ -568,10 +563,6 @@ describe("VaultV2Component", () => { itemsSvc.cipherCount$.next(10); hasPremiumFromAnySource$.next(false); - vaultProfileSvc.getProfileCreationDate = jest - .fn() - .mockResolvedValue(new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)); // 3 days ago - (nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => { return of(type === NudgeType.PremiumUpgrade); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 4678e2733eb..51e735fb1ef 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 @@ -5,26 +5,24 @@ import { Component, DestroyRef, effect, inject, OnDestroy, OnInit } from "@angul import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router, RouterModule } from "@angular/router"; import { + BehaviorSubject, combineLatest, distinctUntilChanged, filter, firstValueFrom, - from, map, Observable, shareReplay, switchMap, take, - withLatestFrom, tap, - BehaviorSubject, + withLatestFrom, } from "rxjs"; import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; 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 { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { DeactivatedOrg, NoResults, VaultOpen } from "@bitwarden/assets/svg"; import { AutoConfirmExtensionSetupDialogComponent, @@ -158,18 +156,10 @@ export class VaultV2Component implements OnInit, OnDestroy { }), ); - protected skeletonFeatureFlag$ = this.configService.getFeatureFlag$( - FeatureFlag.VaultLoadingSkeletons, - ); - protected premiumSpotlightFeatureFlag$ = this.configService.getFeatureFlag$( FeatureFlag.BrowserPremiumSpotlight, ); - private showPremiumNudgeSpotlight$ = this.activeUserId$.pipe( - switchMap((userId) => this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId)), - ); - protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; protected allFilters$ = this.vaultPopupListFiltersService.allFilters$; @@ -177,38 +167,39 @@ export class VaultV2Component implements OnInit, OnDestroy { protected hasPremium$ = this.activeUserId$.pipe( switchMap((userId) => this.billingAccountService.hasPremiumFromAnySource$(userId)), ); - protected accountAgeInDays$ = this.activeUserId$.pipe( - switchMap((userId) => { - const creationDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)); - return creationDate$.pipe( - map((creationDate) => { - if (!creationDate) { - return 0; - } - const ageInMilliseconds = Date.now() - creationDate.getTime(); - return Math.floor(ageInMilliseconds / (1000 * 60 * 60 * 24)); - }), - ); + protected accountAgeInDays$ = this.accountService.activeAccount$.pipe( + map((account) => { + if (!account || !account.creationDate) { + return 0; + } + const creationDate = account.creationDate; + const ageInMilliseconds = Date.now() - creationDate.getTime(); + return Math.floor(ageInMilliseconds / (1000 * 60 * 60 * 24)); }), ); protected showPremiumSpotlight$ = combineLatest([ this.premiumSpotlightFeatureFlag$, - this.showPremiumNudgeSpotlight$, + this.activeUserId$.pipe( + switchMap((userId) => + this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId), + ), + ), this.showHasItemsVaultSpotlight$, this.hasPremium$, this.cipherCount$, this.accountAgeInDays$, ]).pipe( - map( - ([featureFlagEnabled, showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) => + map(([featureFlagEnabled, showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) => { + return ( featureFlagEnabled && showPremiumNudge && !showHasItemsNudge && !hasPremium && count >= 5 && - age >= 7, - ), + age >= 7 + ); + }), shareReplay({ bufferSize: 1, refCount: true }), ); @@ -216,20 +207,14 @@ export class VaultV2Component implements OnInit, OnDestroy { PremiumUpgradeDialogComponent.open(this.dialogService); } - /** When true, show spinner loading state */ - protected showSpinnerLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe( - map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled), - ); - /** When true, show skeleton loading state with debouncing to prevent flicker */ protected showSkeletonsLoaders$ = combineLatest([ this.loading$, this.searchService.isCipherSearching$, this.vaultItemsTransferService.transferInProgress$, - this.skeletonFeatureFlag$, ]).pipe( - map(([loading, cipherSearching, transferInProgress, skeletonsEnabled]) => { - return (loading || cipherSearching || transferInProgress) && skeletonsEnabled; + map(([loading, cipherSearching, transferInProgress]) => { + return loading || cipherSearching || transferInProgress; }), distinctUntilChanged(), skeletonLoadingDelay(), @@ -273,7 +258,6 @@ export class VaultV2Component implements OnInit, OnDestroy { private router: Router, private autoConfirmService: AutomaticUserConfirmationService, private toastService: ToastService, - private vaultProfileService: VaultProfileService, private billingAccountService: BillingAccountProfileStateService, private liveAnnouncer: LiveAnnouncer, private i18nService: I18nService, 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 03eb701704f..8ac6de75997 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 @@ -26,7 +26,7 @@ } - @if ((archiveFlagEnabled$ | async) && cipher.isArchived) { + @if ((archiveFlagEnabled$ | async) && cipher.isArchived && !cipher.isDeleted) { } @for (childFolder of folder().children; track childFolder.node.id) { - + } } @else { 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 455f9177c4d..e3b4493ec7d 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -4,6 +4,7 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { + combineLatest, firstValueFrom, Subject, takeUntil, @@ -70,6 +71,7 @@ import { CipherFormModule, CipherViewComponent, CollectionAssignmentResult, + createFilterFunction, DecryptionFailureDialogComponent, DefaultChangeLoginPasswordService, DefaultCipherFormConfigService, @@ -79,6 +81,7 @@ import { VaultFilter, VaultFilterServiceAbstraction as VaultFilterService, RoutedVaultFilterBridgeService, + RoutedVaultFilterService, VaultItemsTransferService, DefaultVaultItemsTransferService, } from "@bitwarden/vault"; @@ -216,6 +219,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { private policyService: PolicyService, private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService, + private routedVaultFilterService: RoutedVaultFilterService, private vaultFilterService: VaultFilterService, private vaultItemTransferService: VaultItemsTransferService, ) {} @@ -234,9 +238,16 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { }); // Subscribe to filter changes from router params via the bridge service - this.routedVaultFilterBridgeService.activeFilter$ + // Use combineLatest to react to changes in both the filter and archive flag + combineLatest([ + this.routedVaultFilterBridgeService.activeFilter$, + this.routedVaultFilterService.filter$, + this.cipherArchiveService.hasArchiveFlagEnabled$, + ]) .pipe( - switchMap((vaultFilter: VaultFilter) => from(this.applyVaultFilter(vaultFilter))), + switchMap(([vaultFilter, routedFilter, archiveEnabled]) => + from(this.applyVaultFilter(vaultFilter, routedFilter, archiveEnabled)), + ), takeUntil(this.componentIsDestroyed$), ) .subscribe(); @@ -553,7 +564,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { } } - if (!cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) { + if (userCanArchive && !cipher.isDeleted && !cipher.isArchived) { menu.push({ label: this.i18nService.t("archiveVerb"), click: async () => { @@ -568,7 +579,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { }); } - if (cipher.isArchived) { + if (cipher.isArchived && !cipher.isDeleted) { menu.push({ label: this.i18nService.t("unArchive"), click: async () => { @@ -789,47 +800,19 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { await this.go().catch(() => {}); } - /** - * Wraps a filter function to handle CipherListView objects. - * CipherListView has a different type structure where type can be a string or object. - * This wrapper converts it to CipherView-compatible structure before filtering. - */ - private wrapFilterForCipherListView( - filterFn: (cipher: CipherView) => boolean, - ): (cipher: CipherViewLike) => boolean { - return (cipher: CipherViewLike) => { - // For CipherListView, create a proxy object with the correct type property - if (CipherViewLikeUtils.isCipherListView(cipher)) { - const proxyCipher = { - ...cipher, - type: CipherViewLikeUtils.getType(cipher), - // Normalize undefined organizationId to null for filter compatibility - organizationId: cipher.organizationId ?? null, - // Normalize empty string folderId to null for filter compatibility - folderId: cipher.folderId ? cipher.folderId : null, - // Explicitly include isDeleted and isArchived since they might be getters - isDeleted: CipherViewLikeUtils.isDeleted(cipher), - isArchived: CipherViewLikeUtils.isArchived(cipher), - }; - return filterFn(proxyCipher as any); - } - }; - } - - async applyVaultFilter(vaultFilter: VaultFilter) { + async applyVaultFilter( + vaultFilter: VaultFilter, + routedFilter: Parameters[0], + archiveEnabled: boolean, + ) { this.searchBarService.setPlaceholderText( this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)), ); this.activeFilter = vaultFilter; - const originalFilterFn = this.activeFilter.buildFilter(); - const wrappedFilterFn = this.wrapFilterForCipherListView(originalFilterFn); + const filterFn = createFilterFunction(routedFilter, archiveEnabled); - await this.vaultItemsComponent?.reload( - wrappedFilterFn, - vaultFilter.isDeleted, - vaultFilter.isArchived, - ); + await this.vaultItemsComponent?.reload(filterFn, vaultFilter.isDeleted, vaultFilter.isArchived); } private getAvailableCollections(cipher: CipherView): CollectionView[] { @@ -970,7 +953,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { this.addOrganizationId = collections[0].organizationId; this.addCollectionIds = [this.activeFilter.collectionId]; } - } else if (this.activeFilter.organizationId) { + } else if (this.activeFilter.organizationId && this.activeFilter.organizationId !== "MyVault") { this.addOrganizationId = this.activeFilter.organizationId; } else { // clear out organizationId when the user switches to a personal vault filter 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 0af73bf7d8a..5e3de1e6a14 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.html +++ b/apps/desktop/src/vault/app/vault/item-footer.component.html @@ -11,61 +11,66 @@ > {{ submitButtonText() }} - - - + @if (!cipher.isDeleted && action === "view") { + + } + + @if (action === "edit" || action === "clone" || action === "add") { + + } + + @if (cipher.isDeleted && cipher.permissions.restore) { + + } + @if (showCloneOption) { } -
- - - -
+ @if (hasFooterAction) { +
+ @if (showArchiveButton) { + + } + + @if (showUnarchiveButton) { + + } + + +
+ }
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 d601f46e430..8164a1f4a67 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -97,7 +97,7 @@ export class ItemFooterComponent implements OnInit, OnChanges { } async ngOnChanges(changes: SimpleChanges) { - if (changes.cipher) { + if (changes.cipher || changes.action) { await this.checkArchiveState(); } } @@ -255,12 +255,15 @@ export class ItemFooterComponent implements OnInit, OnChanges { this.userCanArchive = userCanArchive; this.showArchiveButton = - cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived; + cipherCanBeArchived && + userCanArchive && + (this.action === "view" || this.action === "edit") && + !this.cipher.isArchived; // A user should always be able to unarchive an archived item this.showUnarchiveButton = hasArchiveFlagEnabled && - this.action === "view" && + (this.action === "view" || this.action === "edit") && this.cipher.isArchived && !this.cipher.isDeleted; } diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html index 8c73891dc09..9917a1b988c 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.html @@ -121,7 +121,7 @@ }}" [attr.aria-pressed]="activeFilter.selectedOrganizationId === organization.id" > - +  {{ organization.name }} diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts index 99338ddbb7c..22ad8dc40db 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts @@ -5,6 +5,7 @@ import { Component } from "@angular/core"; import { OrganizationFilterComponent as BaseOrganizationFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/organization-filter.component"; import { DisplayMode } from "@bitwarden/angular/vault/vault-filter/models/display-mode"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ToastService } from "@bitwarden/components"; @@ -50,4 +51,15 @@ export class OrganizationFilterComponent extends BaseOrganizationFilterComponent }); } } + + getIconString(organization: Organization): string { + if ( + organization?.productTierType === ProductTierType.Free || + organization?.productTierType === ProductTierType.Families + ) { + return "bwi-family"; + } else { + return "bwi-business"; + } + } } diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index efbdee97798..458ddd666b8 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -518,6 +518,7 @@ export class VaultV2Component } const dialogRef = AttachmentsV2Component.open(this.dialogService, { cipherId: this.cipherId as CipherId, + canEditCipher: this.cipher().edit, }); const result = await firstValueFrom(dialogRef.closed).catch((): any => null); if ( @@ -642,77 +643,80 @@ export class VaultV2Component }); } - switch (cipher.type) { - case CipherType.Login: - if ( - cipher.login.canLaunch || - cipher.login.username != null || - cipher.login.password != null - ) { - menu.push({ type: "separator" }); - } - if (cipher.login.canLaunch) { - menu.push({ - label: this.i18nService.t("launch"), - click: () => this.platformUtilsService.launchUri(cipher.login.launchUri), - }); - } - if (cipher.login.username != null) { - menu.push({ - label: this.i18nService.t("copyUsername"), - click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"), - }); - } - if (cipher.login.password != null && cipher.viewPassword) { - menu.push({ - label: this.i18nService.t("copyPassword"), - click: () => { - this.copyValue(cipher, cipher.login.password, "password", "Password"); - this.eventCollectionService - .collect(EventType.Cipher_ClientCopiedPassword, cipher.id) - .catch(() => {}); - }, - }); - } - if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) { - menu.push({ - label: this.i18nService.t("copyVerificationCodeTotp"), - click: async () => { - const value = await firstValueFrom( - this.totpService.getCode$(cipher.login.totp), - ).catch((): any => null); - if (value) { - this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); - } - }, - }); - } - break; - case CipherType.Card: - if (cipher.card.number != null || cipher.card.code != null) { - menu.push({ type: "separator" }); - } - if (cipher.card.number != null) { - menu.push({ - label: this.i18nService.t("copyNumber"), - click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"), - }); - } - if (cipher.card.code != null) { - menu.push({ - label: this.i18nService.t("copySecurityCode"), - click: () => { - this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code"); - this.eventCollectionService - .collect(EventType.Cipher_ClientCopiedCardCode, cipher.id) - .catch(() => {}); - }, - }); - } - break; - default: - break; + if (!cipher.isDeleted) { + switch (cipher.type) { + case CipherType.Login: + if ( + cipher.login.canLaunch || + cipher.login.username != null || + cipher.login.password != null + ) { + menu.push({ type: "separator" }); + } + if (cipher.login.canLaunch) { + menu.push({ + label: this.i18nService.t("launch"), + click: () => this.platformUtilsService.launchUri(cipher.login.launchUri), + }); + } + if (cipher.login.username != null) { + menu.push({ + label: this.i18nService.t("copyUsername"), + click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"), + }); + } + if (cipher.login.password != null && cipher.viewPassword) { + menu.push({ + label: this.i18nService.t("copyPassword"), + click: () => { + this.copyValue(cipher, cipher.login.password, "password", "Password"); + this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedPassword, cipher.id) + .catch(() => {}); + }, + }); + } + if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) { + menu.push({ + label: this.i18nService.t("copyVerificationCodeTotp"), + click: async () => { + const value = await firstValueFrom( + this.totpService.getCode$(cipher.login.totp), + ).catch((): any => null); + if (value) { + this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); + } + }, + }); + } + break; + case CipherType.Card: + if (cipher.card.number != null || cipher.card.code != null) { + menu.push({ type: "separator" }); + } + if (cipher.card.number != null) { + menu.push({ + label: this.i18nService.t("copyNumber"), + click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"), + }); + } + if (cipher.card.code != null) { + menu.push({ + label: this.i18nService.t("copySecurityCode"), + click: () => { + this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code"); + this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedCardCode, cipher.id) + .catch(() => {}); + }, + }); + } + break; + default: + break; + } } + invokeMenu(menu); } diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 6d27e12537a..27036e16240 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -15,6 +15,12 @@ RUN if [ "${LICENSE_TYPE}" != "commercial" ] ; then \ rm -rf node_modules/@bitwarden/commercial-sdk-internal ; \ fi +# Override SDK if custom artifacts are present +RUN if [ -d "sdk-internal" ]; then \ + echo "Overriding SDK with custom artifacts from sdk-internal" ; \ + npm link ./sdk-internal ; \ + fi + WORKDIR /source/apps/web ARG NPM_COMMAND=dist:bit:selfhost RUN npm run ${NPM_COMMAND} diff --git a/apps/web/package.json b/apps/web/package.json index 0e844fbbe79..033c5b000bf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2026.1.0", + "version": "2026.1.1", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts b/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts index e9cf87a114d..e174d01a75d 100644 --- a/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts +++ b/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts @@ -2,7 +2,6 @@ import { TestBed } from "@angular/core/testing"; import { ReplaySubject } from "rxjs"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Environment, EnvironmentService, @@ -46,23 +45,16 @@ describe("PeopleTableDataSource", () => { isCloud: () => false, } as Environment); - const mockConfigService = { - getFeatureFlag$: jest.fn(() => featureFlagSubject.asObservable()), - } as any; - const mockEnvironmentService = { environment$: environmentSubject.asObservable(), } as any; TestBed.configureTestingModule({ - providers: [ - { provide: ConfigService, useValue: mockConfigService }, - { provide: EnvironmentService, useValue: mockEnvironmentService }, - ], + providers: [{ provide: EnvironmentService, useValue: mockEnvironmentService }], }); dataSource = TestBed.runInInjectionContext( - () => new TestPeopleTableDataSource(mockConfigService, mockEnvironmentService), + () => new TestPeopleTableDataSource(mockEnvironmentService), ); }); diff --git a/apps/web/src/app/admin-console/common/people-table-data-source.ts b/apps/web/src/app/admin-console/common/people-table-data-source.ts index d39a4f29653..a3ffbaeb7b5 100644 --- a/apps/web/src/app/admin-console/common/people-table-data-source.ts +++ b/apps/web/src/app/admin-console/common/people-table-data-source.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { computed, Signal } from "@angular/core"; +import { Signal } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { Observable, Subject, map } from "rxjs"; @@ -9,8 +9,6 @@ import { ProviderUserStatusType, } from "@bitwarden/common/admin-console/enums"; import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { TableDataSource } from "@bitwarden/components"; @@ -27,8 +25,7 @@ export type ProviderUser = ProviderUserUserDetailsResponse; export const MaxCheckedCount = 500; /** - * Maximum for bulk reinvite operations when the IncreaseBulkReinviteLimitForCloud - * feature flag is enabled on cloud environments. + * Maximum for bulk reinvite limit in cloud environments. */ export const CloudBulkReinviteLimit = 8000; @@ -78,18 +75,15 @@ export abstract class PeopleTableDataSource extends Tab confirmedUserCount: number; revokedUserCount: number; - /** True when increased bulk limit feature is enabled (feature flag + cloud environment) */ + /** True when increased bulk limit feature is enabled (cloud environment) */ readonly isIncreasedBulkLimitEnabled: Signal; - constructor(configService: ConfigService, environmentService: EnvironmentService) { + constructor(environmentService: EnvironmentService) { super(); - const featureFlagEnabled = toSignal( - configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + this.isIncreasedBulkLimitEnabled = toSignal( + environmentService.environment$.pipe(map((env) => env.isCloud())), ); - const isCloud = toSignal(environmentService.environment$.pipe(map((env) => env.isCloud()))); - - this.isIncreasedBulkLimitEnabled = computed(() => featureFlagEnabled() && isCloud()); } override set data(data: T[]) { @@ -224,12 +218,9 @@ export abstract class PeopleTableDataSource extends Tab } /** - * Gets checked users with optional limiting based on the IncreaseBulkReinviteLimitForCloud feature flag. + * Returns checked users in visible order, optionally limited to the specified count. * - * When the feature flag is enabled: Returns checked users in visible order, limited to the specified count. - * When the feature flag is disabled: Returns all checked users without applying any limit. - * - * @param limit The maximum number of users to return (only applied when feature flag is enabled) + * @param limit The maximum number of users to return * @returns The checked users array */ getCheckedUsersWithLimit(limit: number): T[] { diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index b00e4d9840d..2d1fde10856 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -26,7 +26,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getById } from "@bitwarden/common/platform/misc"; -import { BannerModule, IconModule } from "@bitwarden/components"; +import { BannerModule, SvgModule } from "@bitwarden/components"; import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; @@ -47,7 +47,7 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module"; RouterModule, JslibModule, WebLayoutModule, - IconModule, + SvgModule, OrgSwitcherComponent, BannerModule, TaxIdWarningComponent, diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts index dc7b079fefe..154a683b0e1 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts @@ -1,10 +1,21 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, Inject } from "@angular/core"; +import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; -import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { + OrganizationUserApiService, + OrganizationUserService, +} from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { 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 { getById } from "@bitwarden/common/platform/misc"; import { DIALOG_DATA, DialogService } from "@bitwarden/components"; import { BulkUserDetails } from "./bulk-status.component"; @@ -34,10 +45,15 @@ export class BulkRestoreRevokeComponent { error: string; showNoMasterPasswordWarning = false; nonCompliantMembers: boolean = false; + organization$: Observable; constructor( protected i18nService: I18nService, private organizationUserApiService: OrganizationUserApiService, + private organizationUserService: OrganizationUserService, + private accountService: AccountService, + private organizationService: OrganizationService, + private configService: ConfigService, @Inject(DIALOG_DATA) protected data: BulkRestoreDialogParams, ) { this.isRevoking = data.isRevoking; @@ -46,6 +62,18 @@ export class BulkRestoreRevokeComponent { this.showNoMasterPasswordWarning = this.users.some( (u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false, ); + + this.organization$ = accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => organizationService.organizations$(userId)), + getById(this.organizationId), + map((organization) => { + if (organization == null) { + throw new Error("Organization not found"); + } + return organization; + }), + ); } get bulkTitle() { @@ -83,9 +111,22 @@ export class BulkRestoreRevokeComponent { userIds, ); } else { - return await this.organizationUserApiService.restoreManyOrganizationUsers( - this.organizationId, - userIds, + return await firstValueFrom( + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.DefaultUserCollectionRestore), + this.organization$, + ]).pipe( + switchMap(([enabled, organization]) => { + if (enabled) { + return this.organizationUserService.bulkRestoreUsers(organization, userIds); + } else { + return this.organizationUserApiService.restoreManyOrganizationUsers( + this.organizationId, + userIds, + ); + } + }), + ), ); } } diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts index 1fa4c8bf8f7..6848f76286f 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts @@ -17,11 +17,9 @@ import { import { CollectionAdminService, OrganizationUserApiService, + OrganizationUserService, } from "@bitwarden/admin-console/common"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserStatusType, OrganizationUserType, @@ -36,8 +34,10 @@ 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 { getById } from "@bitwarden/common/platform/misc"; import { DIALOG_DATA, DialogConfig, @@ -197,14 +197,19 @@ export class MemberDialogComponent implements OnDestroy { private toastService: ToastService, private configService: ConfigService, private deleteManagedMemberWarningService: DeleteManagedMemberWarningService, + private organizationUserService: OrganizationUserService, ) { this.organization$ = accountService.activeAccount$.pipe( - switchMap((account) => - organizationService - .organizations$(account?.id) - .pipe(getOrganizationById(this.params.organizationId)) - .pipe(shareReplay({ refCount: true, bufferSize: 1 })), - ), + getUserId, + switchMap((userId) => organizationService.organizations$(userId)), + getById(this.params.organizationId), + map((organization) => { + if (organization == null) { + throw new Error("Organization not found"); + } + return organization; + }), + shareReplay({ refCount: true, bufferSize: 1 }), ); let userDetails$; @@ -633,9 +638,26 @@ export class MemberDialogComponent implements OnDestroy { return; } - await this.organizationUserApiService.restoreOrganizationUser( - this.params.organizationId, - this.params.organizationUserId, + await firstValueFrom( + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.DefaultUserCollectionRestore), + this.organization$, + this.editParams$, + ]).pipe( + switchMap(([enabled, organization, params]) => { + if (enabled) { + return this.organizationUserService.restoreUser( + organization, + params.organizationUserId, + ); + } else { + return this.organizationUserApiService.restoreOrganizationUser( + params.organizationId, + params.organizationUserId, + ); + } + }), + ), ); this.toastService.showToast({ diff --git a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts index 99fd81aa48d..93960820fbb 100644 --- a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts @@ -33,7 +33,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -124,7 +123,6 @@ export class MembersComponent extends BaseMembersComponent private policyApiService: PolicyApiServiceAbstraction, private organizationMetadataService: OrganizationMetadataServiceAbstraction, private memberExportService: MemberExportService, - private configService: ConfigService, private environmentService: EnvironmentService, ) { super( @@ -139,7 +137,7 @@ export class MembersComponent extends BaseMembersComponent toastService, ); - this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + this.dataSource = new MembersTableDataSource(this.environmentService); const organization$ = this.route.params.pipe( concatMap((params) => 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 9d367657d55..e3ed575d81b 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 @@ -33,7 +33,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -100,7 +99,6 @@ export class vNextMembersComponent { private policyService = inject(PolicyService); private policyApiService = inject(PolicyApiServiceAbstraction); private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); - private configService = inject(ConfigService); private environmentService = inject(EnvironmentService); private memberExportService = inject(MemberExportService); @@ -114,7 +112,7 @@ export class vNextMembersComponent { protected statusToggle = new BehaviorSubject(undefined); protected readonly dataSource: Signal = signal( - new MembersTableDataSource(this.configService, this.environmentService), + new MembersTableDataSource(this.environmentService), ); protected readonly organization: Signal; protected readonly firstLoaded: WritableSignal = signal(false); @@ -389,7 +387,7 @@ export class vNextMembersComponent { // Capture the original count BEFORE enforcing the limit const originalInvitedCount = allInvitedUsers.length; - // When feature flag is enabled, limit invited users and uncheck the excess + // In cloud environments, limit invited users and uncheck the excess let filteredUsers: OrganizationUserView[]; if (this.dataSource().isIncreasedBulkLimitEnabled()) { filteredUsers = this.dataSource().limitAndUncheckExcess( @@ -418,7 +416,7 @@ export class vNextMembersComponent { this.validationService.showError(result.failed); } - // When feature flag is enabled, show toast instead of dialog + // In cloud environments, show toast instead of dialog if (this.dataSource().isIncreasedBulkLimitEnabled()) { const selectedCount = originalInvitedCount; const invitedCount = filteredUsers.length; @@ -441,7 +439,7 @@ export class vNextMembersComponent { }); } } else { - // Feature flag disabled - show legacy dialog + // In self-hosted environments, show legacy dialog await this.memberDialogManager.openBulkStatusDialog( users, filteredUsers, @@ -514,7 +512,7 @@ export class vNextMembersComponent { if (result.error != null) { this.toastService.showToast({ variant: "error", - message: this.i18nService.t(result.error), + message: result.error, }); this.logService.error(result.error); return; 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 1df285d7ba2..5924c2f7814 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from "@angular/core/testing"; import { MockProxy, mock } from "jest-mock-extended"; -import { of } from "rxjs"; +import { of, throwError } from "rxjs"; import { OrganizationUserApiService, @@ -178,25 +178,64 @@ describe("MemberActionsService", () => { }); describe("restoreUser", () => { - it("should successfully restore a user", async () => { - organizationUserApiService.restoreOrganizationUser.mockResolvedValue(undefined); + describe("when feature flag is enabled", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + }); - const result = await service.restoreUser(mockOrganization, userIdToManage); + it("should call organizationUserService.restoreUser", async () => { + organizationUserService.restoreUser.mockReturnValue(of(undefined)); - expect(result).toEqual({ success: true }); - expect(organizationUserApiService.restoreOrganizationUser).toHaveBeenCalledWith( - organizationId, - userIdToManage, - ); + const result = await service.restoreUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: true }); + expect(organizationUserService.restoreUser).toHaveBeenCalledWith( + mockOrganization, + userIdToManage, + ); + expect(organizationUserApiService.restoreOrganizationUser).not.toHaveBeenCalled(); + }); + + it("should handle errors from organizationUserService.restoreUser", async () => { + const errorMessage = "Restore failed"; + organizationUserService.restoreUser.mockReturnValue( + throwError(() => new Error(errorMessage)), + ); + + const result = await service.restoreUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: false, error: errorMessage }); + }); }); - it("should handle restore errors", async () => { - const errorMessage = "Restore failed"; - organizationUserApiService.restoreOrganizationUser.mockRejectedValue(new Error(errorMessage)); + describe("when feature flag is disabled", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + }); - const result = await service.restoreUser(mockOrganization, userIdToManage); + it("should call organizationUserApiService.restoreOrganizationUser", async () => { + organizationUserApiService.restoreOrganizationUser.mockResolvedValue(undefined); - expect(result).toEqual({ success: false, error: errorMessage }); + const result = await service.restoreUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: true }); + expect(organizationUserApiService.restoreOrganizationUser).toHaveBeenCalledWith( + organizationId, + userIdToManage, + ); + expect(organizationUserService.restoreUser).not.toHaveBeenCalled(); + }); + + it("should handle errors", async () => { + const errorMessage = "Restore failed"; + organizationUserApiService.restoreOrganizationUser.mockRejectedValue( + new Error(errorMessage), + ); + + const result = await service.restoreUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: false, error: errorMessage }); + }); }); }); @@ -279,308 +318,247 @@ describe("MemberActionsService", () => { }); describe("bulkReinvite", () => { - const userIds = [newGuid() as UserId, newGuid() as UserId, newGuid() as UserId]; + it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => { + const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId); + const mockResponse = new ListResponse( + { + data: userIdsBatch.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - describe("when feature flag is false", () => { - beforeEach(() => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - }); + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - it("should successfully reinvite multiple users", async () => { - const mockResponse = new ListResponse( - { - data: userIds.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - - const result = await service.bulkReinvite(mockOrganization, userIds); - - expect(result.failed).toEqual([]); - expect(result.successful).toBeDefined(); - expect(result.successful).toEqual(mockResponse); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( - organizationId, - userIds, - ); - }); - - it("should handle bulk reinvite errors", async () => { - const errorMessage = "Bulk reinvite failed"; - organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( - new Error(errorMessage), - ); - - const result = await service.bulkReinvite(mockOrganization, userIds); - - expect(result.successful).toBeUndefined(); - expect(result.failed).toHaveLength(3); - expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage }); - }); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.failed).toHaveLength(0); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(1); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( + organizationId, + userIdsBatch, + ); }); - describe("when feature flag is true (batching behavior)", () => { - beforeEach(() => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - }); - it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => { - const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId); - const mockResponse = new ListResponse( - { - data: userIdsBatch.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); - expect(result.failed).toHaveLength(0); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 1, - ); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( - organizationId, - userIdsBatch, - ); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => { - const totalUsers = REQUESTS_PER_BATCH + 100; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.failed).toHaveLength(0); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( + 1, + organizationId, + userIdsBatch.slice(0, REQUESTS_PER_BATCH), + ); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( + 2, + organizationId, + userIdsBatch.slice(REQUESTS_PER_BATCH), + ); + }); - const mockResponse2 = new ListResponse( - { - data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should aggregate results across multiple successful batches", async () => { + const totalUsers = REQUESTS_PER_BATCH + 50; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(totalUsers); - expect(result.failed).toHaveLength(0); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 2, - ); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( - 1, - organizationId, - userIdsBatch.slice(0, REQUESTS_PER_BATCH), - ); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( - 2, - organizationId, - userIdsBatch.slice(REQUESTS_PER_BATCH), - ); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should aggregate results across multiple successful batches", async () => { - const totalUsers = REQUESTS_PER_BATCH + 50; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data); + expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); + expect(result.failed).toHaveLength(0); + }); - const mockResponse2 = new ListResponse( - { - data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should handle mixed individual errors across multiple batches", async () => { + const totalUsers = REQUESTS_PER_BATCH + 4; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({ + id, + error: index % 10 === 0 ? "Rate limit exceeded" : null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: [ + { id: userIdsBatch[REQUESTS_PER_BATCH], error: null }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" }, + ], + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(totalUsers); - expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual( - mockResponse1.data, - ); - expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); - expect(result.failed).toHaveLength(0); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should handle mixed individual errors across multiple batches", async () => { - const totalUsers = REQUESTS_PER_BATCH + 4; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({ - id, - error: index % 10 === 0 ? "Rate limit exceeded" : null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + // Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch + // Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values + const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1; + const expectedFailuresInBatch2 = 2; + const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2; + const expectedSuccesses = totalUsers - expectedTotalFailures; - const mockResponse2 = new ListResponse( - { - data: [ - { id: userIdsBatch[REQUESTS_PER_BATCH], error: null }, - { id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" }, - { id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null }, - { id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" }, - ], - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(expectedSuccesses); + expect(result.failed).toHaveLength(expectedTotalFailures); + expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true); + expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true); + expect(result.failed.some((f) => f.error === "User suspended")).toBe(true); + }); - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + it("should aggregate all failures when all batches fail", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const errorMessage = "All batches failed"; - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( + new Error(errorMessage), + ); - // Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch - // Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values - const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1; - const expectedFailuresInBatch2 = 2; - const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2; - const expectedSuccesses = totalUsers - expectedTotalFailures; + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(expectedSuccesses); - expect(result.failed).toHaveLength(expectedTotalFailures); - expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true); - expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true); - expect(result.failed.some((f) => f.error === "User suspended")).toBe(true); - }); + expect(result.successful).toBeUndefined(); + expect(result.failed).toHaveLength(totalUsers); + expect(result.failed.every((f) => f.error === errorMessage)).toBe(true); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); + }); - it("should aggregate all failures when all batches fail", async () => { - const totalUsers = REQUESTS_PER_BATCH + 100; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - const errorMessage = "All batches failed"; + it("should handle empty data in batch response", async () => { + const totalUsers = REQUESTS_PER_BATCH + 50; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( - new Error(errorMessage), - ); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: [], + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeUndefined(); - expect(result.failed).toHaveLength(totalUsers); - expect(result.failed.every((f) => f.error === errorMessage)).toBe(true); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 2, - ); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should handle empty data in batch response", async () => { - const totalUsers = REQUESTS_PER_BATCH + 50; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.failed).toHaveLength(0); + }); - const mockResponse2 = new ListResponse( - { - data: [], - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should process batches sequentially in order", async () => { + const totalUsers = REQUESTS_PER_BATCH * 2; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const callOrder: number[] = []; - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation( + async (orgId, ids) => { + const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2; + callOrder.push(batchIndex); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + return new ListResponse( + { + data: ids.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + }, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); - expect(result.failed).toHaveLength(0); - }); + await service.bulkReinvite(mockOrganization, userIdsBatch); - it("should process batches sequentially in order", async () => { - const totalUsers = REQUESTS_PER_BATCH * 2; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - const callOrder: number[] = []; - - organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation( - async (orgId, ids) => { - const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2; - callOrder.push(batchIndex); - - return new ListResponse( - { - data: ids.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); - }, - ); - - await service.bulkReinvite(mockOrganization, userIdsBatch); - - expect(callOrder).toEqual([1, 2]); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 2, - ); - }); + expect(callOrder).toEqual([1, 2]); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); }); }); 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 5833238209c..3b0db124a6b 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,5 +1,5 @@ import { inject, Injectable, signal } from "@angular/core"; -import { lastValueFrom, firstValueFrom } from "rxjs"; +import { lastValueFrom, firstValueFrom, switchMap } from "rxjs"; import { OrganizationUserApiService, @@ -10,8 +10,8 @@ import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; import { - OrganizationUserType, OrganizationUserStatusType, + OrganizationUserType, } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { assertNonNullish } from "@bitwarden/common/auth/utils"; @@ -119,7 +119,21 @@ export class MemberActionsService { async restoreUser(organization: Organization, userId: string): Promise { this.startProcessing(); try { - await this.organizationUserApiService.restoreOrganizationUser(organization.id, userId); + await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.DefaultUserCollectionRestore).pipe( + switchMap((enabled) => { + if (enabled) { + return this.organizationUserService.restoreUser(organization, userId); + } else { + return this.organizationUserApiService.restoreOrganizationUser( + organization.id, + userId, + ); + } + }), + ), + ); + this.organizationMetadataService.refreshMetadataCache(); return { success: true }; } catch (error) { @@ -175,18 +189,9 @@ export class MemberActionsService { async bulkReinvite(organization: Organization, userIds: UserId[]): Promise { this.startProcessing(); try { - const increaseBulkReinviteLimitForCloud = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) => + this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch), ); - if (increaseBulkReinviteLimitForCloud) { - return await this.vNextBulkReinvite(organization, userIds); - } else { - const result = await this.organizationUserApiService.postManyOrganizationUserReinvite( - organization.id, - userIds, - ); - return { successful: result, failed: [] }; - } } catch (error) { return { failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), @@ -196,15 +201,6 @@ export class MemberActionsService { } } - async vNextBulkReinvite( - organization: Organization, - userIds: UserId[], - ): Promise { - return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) => - this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch), - ); - } - allowResetPassword( orgUser: OrganizationUserView, organization: Organization, diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts index afc16e72373..69feb2b86bc 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.spec.ts @@ -6,6 +6,7 @@ import { BehaviorSubject, of } from "rxjs"; import { OrganizationUserApiService, OrganizationUserResetPasswordDetailsResponse, + OrganizationUserResetPasswordRequest, } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -13,6 +14,15 @@ import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; +import { + MasterKeyWrappedUserKey, + MasterPasswordAuthenticationData, + MasterPasswordAuthenticationHash, + MasterPasswordSalt, + MasterPasswordUnlockData, +} from "@bitwarden/common/key-management/master-password/types/master-password.types"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -21,7 +31,7 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp import { CsprngArray } from "@bitwarden/common/types/csprng"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { UserKey, OrgKey, MasterKey } from "@bitwarden/common/types/key"; -import { KdfType, KeyService } from "@bitwarden/key-management"; +import { DEFAULT_KDF_CONFIG, KdfConfig, KdfType, KeyService } from "@bitwarden/key-management"; import { OrganizationUserResetPasswordService } from "./organization-user-reset-password.service"; @@ -39,6 +49,8 @@ describe("OrganizationUserResetPasswordService", () => { let i18nService: MockProxy; const mockUserId = Utils.newGuid() as UserId; let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; + let configService: MockProxy; beforeAll(() => { keyService = mock(); @@ -48,6 +60,8 @@ describe("OrganizationUserResetPasswordService", () => { organizationApiService = mock(); i18nService = mock(); accountService = mockAccountServiceWith(mockUserId); + masterPasswordService = new FakeMasterPasswordService(); + configService = mock(); sut = new OrganizationUserResetPasswordService( keyService, @@ -57,6 +71,8 @@ describe("OrganizationUserResetPasswordService", () => { organizationApiService, i18nService, accountService, + masterPasswordService, + configService, ); }); @@ -129,13 +145,23 @@ describe("OrganizationUserResetPasswordService", () => { }); }); - describe("resetMasterPassword", () => { + /** + * @deprecated This 'describe' to be removed in PM-28143. When you remove this, check also if there are + * any imports/properties in the test setup above that are now un-used and can also be removed. + */ + describe("resetMasterPassword [PM27086_UpdateAuthenticationApisForInputPassword flag DISABLED]", () => { + const PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled = false; + const mockNewMP = "new-password"; const mockEmail = "test@example.com"; const mockOrgUserId = "test-org-user-id"; const mockOrgId = "test-org-id"; beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue( + PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled, + ); + organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue( new OrganizationUserResetPasswordDetailsResponse({ kdf: KdfType.PBKDF2_SHA256, @@ -185,6 +211,164 @@ describe("OrganizationUserResetPasswordService", () => { }); }); + describe("resetMasterPassword [PM27086_UpdateAuthenticationApisForInputPassword flag ENABLED]", () => { + // Mock sut method parameters + const newMasterPassword = "new-master-password"; + const email = "user@example.com"; + const orgUserId = "org-user-id"; + const orgId = "org-id" as OrganizationId; + + // Mock feature flag value + const PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled = true; + + // Mock method data + let organizationUserResetPasswordDetailsResponse: OrganizationUserResetPasswordDetailsResponse; + let salt: MasterPasswordSalt; + let kdfConfig: KdfConfig; + let authenticationData: MasterPasswordAuthenticationData; + let unlockData: MasterPasswordUnlockData; + let userKey: UserKey; + + beforeEach(() => { + // Mock feature flag value + configService.getFeatureFlag.mockResolvedValue( + PM27086_UpdateAuthenticationApisForInputPasswordFlagEnabled, + ); + + // Mock method data + kdfConfig = DEFAULT_KDF_CONFIG; + + organizationUserResetPasswordDetailsResponse = + new OrganizationUserResetPasswordDetailsResponse({ + organizationUserId: orgUserId, + kdf: kdfConfig.kdfType, + kdfIterations: kdfConfig.iterations, + resetPasswordKey: "test-reset-password-key", + encryptedPrivateKey: "test-encrypted-private-key", + }); + + organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue( + organizationUserResetPasswordDetailsResponse, + ); + + const mockDecryptedOrgKeyBytes = new Uint8Array(64).fill(1); + const mockDecryptedOrgKey = new SymmetricCryptoKey(mockDecryptedOrgKeyBytes) as OrgKey; + + keyService.orgKeys$.mockReturnValue( + of({ [orgId]: mockDecryptedOrgKey } as Record), + ); + + const mockDecryptedPrivateKeyBytes = new Uint8Array(64).fill(2); + encryptService.unwrapDecapsulationKey.mockResolvedValue(mockDecryptedPrivateKeyBytes); + + const mockDecryptedUserKeyBytes = new Uint8Array(64).fill(3); + const mockUserKey = new SymmetricCryptoKey(mockDecryptedUserKeyBytes); + encryptService.decapsulateKeyUnsigned.mockResolvedValue(mockUserKey); // returns `SymmetricCryptoKey` + userKey = mockUserKey as UserKey; // type cast to `UserKey` (see code implementation). Points to same object as mockUserKey. + + salt = email as MasterPasswordSalt; + masterPasswordService.mock.emailToSalt.mockReturnValue(salt); + + authenticationData = { + salt, + kdf: kdfConfig, + masterPasswordAuthenticationHash: + "masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash, + }; + + unlockData = { + salt, + kdf: kdfConfig, + masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey, + } as MasterPasswordUnlockData; + + masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue( + authenticationData, + ); + masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(unlockData); + }); + + it("should throw an error if the organizationUserResetPasswordDetailsResponse is nullish", async () => { + // Arrange + organizationUserApiService.getOrganizationUserResetPasswordDetails.mockResolvedValue(null); + + // Act + const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId); + + // Assert + await expect(promise).rejects.toThrow(); + }); + + it("should throw an error if the org key cannot be found", async () => { + // Arrange + keyService.orgKeys$.mockReturnValue(of({} as Record)); + + // Act + const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId); + + // Assert + await expect(promise).rejects.toThrow("No org key found"); + }); + + it("should throw an error if orgKeys$ returns null", async () => { + // Arrange + keyService.orgKeys$.mockReturnValue(of(null)); + + // Act + const promise = sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId); + + // Assert + await expect(promise).rejects.toThrow(); + }); + + it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => { + // Act + await sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId); + + // Assert + const request = OrganizationUserResetPasswordRequest.newConstructor( + authenticationData, + unlockData, + ); + + expect(masterPasswordService.mock.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith( + newMasterPassword, + kdfConfig, + salt, + ); + + expect(masterPasswordService.mock.makeMasterPasswordUnlockData).toHaveBeenCalledWith( + newMasterPassword, + kdfConfig, + salt, + userKey, + ); + + expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledWith( + orgId, + orgUserId, + request, + ); + }); + + it("should call the API method to reset the user's master password", async () => { + // Act + await sut.resetMasterPassword(newMasterPassword, email, orgUserId, orgId); + + // Assert + const request = OrganizationUserResetPasswordRequest.newConstructor( + authenticationData, + unlockData, + ); + expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledTimes(1); + expect(organizationUserApiService.putOrganizationUserResetPassword).toHaveBeenCalledWith( + orgId, + orgUserId, + request, + ); + }); + }); + describe("getPublicKeys", () => { it("should return public keys for organizations that have reset password enrolled", async () => { const result = await sut.getPublicKeys("userId" as UserId); diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts index 88797f86650..bd3dd7fbb0b 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts @@ -12,11 +12,15 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { EncryptedString, EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types"; +import { 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, UserId } from "@bitwarden/common/types/guid"; @@ -47,6 +51,8 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR private organizationApiService: OrganizationApiServiceAbstraction, private i18nService: I18nService, private accountService: AccountService, + private masterPasswordService: MasterPasswordServiceAbstraction, + private configService: ConfigService, ) {} /** @@ -140,6 +146,44 @@ export class OrganizationUserResetPasswordService implements UserKeyRotationKeyR ? new PBKDF2KdfConfig(response.kdfIterations) : new Argon2KdfConfig(response.kdfIterations, response.kdfMemory, response.kdfParallelism); + const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword, + ); + + if (newApisWithInputPasswordFlagEnabled) { + const salt: MasterPasswordSalt = this.masterPasswordService.emailToSalt(email); + + // Create authentication and unlock data + const authenticationData = + await this.masterPasswordService.makeMasterPasswordAuthenticationData( + newMasterPassword, + kdfConfig, + salt, + ); + + const unlockData = await this.masterPasswordService.makeMasterPasswordUnlockData( + newMasterPassword, + kdfConfig, + salt, + existingUserKey, + ); + + // Create request + const request = OrganizationUserResetPasswordRequest.newConstructor( + authenticationData, + unlockData, + ); + + // Change user's password + await this.organizationUserApiService.putOrganizationUserResetPassword( + orgId, + orgUserId, + request, + ); + + return; // EARLY RETURN for flagged code + } + // Create new master key and hash new password const newMasterKey = await this.keyService.makeMasterKey( newMasterPassword, diff --git a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts index c1b175fa988..08897299d81 100644 --- a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts @@ -6,7 +6,7 @@ import { Constructor } from "type-fest"; 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 { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; +import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; @@ -80,7 +80,7 @@ export abstract class BasePolicyEditDefinition { export abstract class BasePolicyEditComponent implements OnInit { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() policyResponse: PolicyResponse | undefined; + @Input() policyResponse: PolicyStatusResponse | undefined; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() policy: BasePolicyEditDefinition | undefined; diff --git a/apps/web/src/app/admin-console/organizations/policies/index.ts b/apps/web/src/app/admin-console/organizations/policies/index.ts index eb614e180e1..8e730d3a6b8 100644 --- a/apps/web/src/app/admin-console/organizations/policies/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/index.ts @@ -5,3 +5,4 @@ export { POLICY_EDIT_REGISTER } from "./policy-register-token"; export { AutoConfirmPolicy } from "./policy-edit-definitions"; export { PolicyEditDialogResult } from "./policy-edit-dialog.component"; export * from "./policy-edit-dialogs"; +export { PolicyOrderPipe } from "./pipes/policy-order.pipe"; diff --git a/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts b/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts new file mode 100644 index 00000000000..02092f05b92 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts @@ -0,0 +1,66 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +import { BasePolicyEditDefinition } from "../base-policy-edit.component"; + +/** + * Order mapping for policies. Policies are ordered according to this mapping. + * Policies not in this mapping will appear at the end, maintaining their relative order. + */ +const POLICY_ORDER_MAP = new Map([ + ["singleOrg", 1], + ["organizationDataOwnership", 2], + ["centralizeDataOwnership", 2], + ["masterPassPolicyTitle", 3], + ["accountRecoveryPolicy", 4], + ["requireSso", 5], + ["automaticAppLoginWithSSO", 6], + ["twoStepLoginPolicyTitle", 7], + ["blockClaimedDomainAccountCreation", 8], + ["sessionTimeoutPolicyTitle", 9], + ["removeUnlockWithPinPolicyTitle", 10], + ["passwordGenerator", 11], + ["uriMatchDetectionPolicy", 12], + ["activateAutofillPolicy", 13], + ["sendOptions", 14], + ["disableSend", 15], + ["restrictedItemTypePolicy", 16], + ["freeFamiliesSponsorship", 17], + ["disableExport", 18], +]); + +/** + * Default order for policies not in the mapping. This ensures unmapped policies + * appear at the end while maintaining their relative order. + */ +const DEFAULT_ORDER = 999; + +@Pipe({ + name: "policyOrder", + standalone: true, +}) +export class PolicyOrderPipe implements PipeTransform { + transform( + policies: readonly BasePolicyEditDefinition[] | null | undefined, + ): BasePolicyEditDefinition[] { + if (policies == null || policies.length === 0) { + return []; + } + + const sortedPolicies = [...policies]; + + sortedPolicies.sort((a, b) => { + const orderA = POLICY_ORDER_MAP.get(a.name) ?? DEFAULT_ORDER; + const orderB = POLICY_ORDER_MAP.get(b.name) ?? DEFAULT_ORDER; + + if (orderA !== orderB) { + return orderA - orderB; + } + + const indexA = policies.indexOf(a); + const indexB = policies.indexOf(b); + return indexA - indexB; + }); + + return sortedPolicies; + } +} diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.html b/apps/web/src/app/admin-console/organizations/policies/policies.component.html index c38092146ab..902c7e79d55 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.html @@ -15,7 +15,7 @@ } @else { - @for (p of policies$ | async; track $index) { + @for (p of policies$ | async | policyOrder; track $index) { @if (p.display$(organization, configService) | 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 1f9a8deaa85..d13a2097628 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 @@ -21,13 +21,14 @@ import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component"; +import { PolicyOrderPipe } from "./pipes/policy-order.pipe"; import { PolicyEditDialogComponent } from "./policy-edit-dialog.component"; import { PolicyListService } from "./policy-list.service"; import { POLICY_EDIT_REGISTER } from "./policy-register-token"; @Component({ templateUrl: "policies.component.html", - imports: [SharedModule, HeaderModule], + imports: [SharedModule, HeaderModule, PolicyOrderPipe], providers: [ safeProvider({ provide: PolicyListService, diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html index 54f166b662e..a8e3236dad8 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html @@ -44,7 +44,7 @@
- +
  1. 1. {{ "autoConfirmExtension1" | i18n }}
  2. diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.html index 63a59208cc0..f979c143a3a 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.html @@ -32,6 +32,7 @@ formControlName="minLength" id="minLength" [min]="MinPasswordLength" + [max]="MaxPasswordLength" />
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts new file mode 100644 index 00000000000..b22f5687dd2 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts @@ -0,0 +1,69 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { MasterPasswordPolicyComponent } from "./master-password.component"; + +describe("MasterPasswordPolicyComponent", () => { + let component: MasterPasswordPolicyComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + { provide: I18nService, useValue: mock() }, + { provide: OrganizationService, useValue: mock() }, + { provide: AccountService, useValue: mock() }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(MasterPasswordPolicyComponent); + component = fixture.componentInstance; + }); + + it("should accept minimum password length of 12", () => { + component.data.patchValue({ minLength: 12 }); + + expect(component.data.get("minLength")?.valid).toBe(true); + }); + + it("should accept maximum password length of 128", () => { + component.data.patchValue({ minLength: 128 }); + + expect(component.data.get("minLength")?.valid).toBe(true); + }); + + it("should reject password length below minimum", () => { + component.data.patchValue({ minLength: 11 }); + + expect(component.data.get("minLength")?.hasError("min")).toBe(true); + }); + + it("should reject password length above maximum", () => { + component.data.patchValue({ minLength: 129 }); + + expect(component.data.get("minLength")?.hasError("max")).toBe(true); + }); + + it("should use correct minimum from Utils", () => { + expect(component.MinPasswordLength).toBe(Utils.minimumPasswordLength); + expect(component.MinPasswordLength).toBe(12); + }); + + it("should use correct maximum from Utils", () => { + expect(component.MaxPasswordLength).toBe(Utils.maximumPasswordLength); + expect(component.MaxPasswordLength).toBe(128); + }); + + it("should have password scores from 0 to 4", () => { + const scores = component.passwordScores.filter((s) => s.value !== null).map((s) => s.value); + + expect(scores).toEqual([0, 1, 2, 3, 4]); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts index e9926b2aeb1..dd2463d718d 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts @@ -34,10 +34,14 @@ export class MasterPasswordPolicy extends BasePolicyEditDefinition { }) export class MasterPasswordPolicyComponent extends BasePolicyEditComponent implements OnInit { MinPasswordLength = Utils.minimumPasswordLength; + MaxPasswordLength = Utils.maximumPasswordLength; data: FormGroup> = this.formBuilder.group({ minComplexity: [null], - minLength: [this.MinPasswordLength, [Validators.min(Utils.minimumPasswordLength)]], + minLength: [ + this.MinPasswordLength, + [Validators.min(Utils.minimumPasswordLength), Validators.max(this.MaxPasswordLength)], + ], requireUpper: [false], requireLower: [false], requireNumbers: [false], diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.spec.ts index f6df56cd83a..21ab7fc71ba 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.spec.ts @@ -4,7 +4,7 @@ import { By } from "@angular/platform-browser"; import { mock } from "jest-mock-extended"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; +import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { @@ -42,8 +42,7 @@ describe("RemoveUnlockWithPinPolicyComponent", () => { }); it("input selected on load when policy enabled", async () => { - component.policyResponse = new PolicyResponse({ - id: "policy1", + component.policyResponse = new PolicyStatusResponse({ organizationId: "org1", type: PolicyType.RemoveUnlockWithPin, enabled: true, @@ -63,8 +62,7 @@ describe("RemoveUnlockWithPinPolicyComponent", () => { }); it("input not selected on load when policy disabled", async () => { - component.policyResponse = new PolicyResponse({ - id: "policy1", + component.policyResponse = new PolicyStatusResponse({ organizationId: "org1", type: PolicyType.RemoveUnlockWithPin, enabled: false, @@ -84,8 +82,7 @@ describe("RemoveUnlockWithPinPolicyComponent", () => { }); it("turn on message label", async () => { - component.policyResponse = new PolicyResponse({ - id: "policy1", + component.policyResponse = new PolicyStatusResponse({ organizationId: "org1", type: PolicyType.RemoveUnlockWithPin, enabled: false, diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts new file mode 100644 index 00000000000..09b2f8961f3 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts @@ -0,0 +1,270 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder } from "@angular/forms"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +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"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components"; +import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; + +import { + AutoConfirmPolicyDialogComponent, + AutoConfirmPolicyDialogData, +} from "./auto-confirm-edit-policy-dialog.component"; + +describe("AutoConfirmPolicyDialogComponent", () => { + let component: AutoConfirmPolicyDialogComponent; + let fixture: ComponentFixture; + + let mockPolicyApiService: MockProxy; + let mockAccountService: FakeAccountService; + let mockOrganizationService: MockProxy; + let mockPolicyService: MockProxy; + let mockRouter: MockProxy; + let mockAutoConfirmService: MockProxy; + let mockDialogRef: MockProxy; + let mockToastService: MockProxy; + let mockI18nService: MockProxy; + let mockKeyService: MockProxy; + + const mockUserId = newGuid() as UserId; + const mockOrgId = newGuid() as OrganizationId; + + const mockDialogData: AutoConfirmPolicyDialogData = { + organizationId: mockOrgId, + policy: { + name: "autoConfirm", + description: "Auto Confirm Policy", + type: PolicyType.AutoConfirm, + component: {} as any, + showDescription: true, + display$: () => of(true), + }, + firstTimeDialog: false, + }; + + const mockOrg = { + id: mockOrgId, + name: "Test Organization", + enabled: true, + isAdmin: true, + canManagePolicies: true, + } as Organization; + + beforeEach(async () => { + mockPolicyApiService = mock(); + mockAccountService = mockAccountServiceWith(mockUserId); + mockOrganizationService = mock(); + mockPolicyService = mock(); + mockRouter = mock(); + mockAutoConfirmService = mock(); + mockDialogRef = mock(); + mockToastService = mock(); + mockI18nService = mock(); + mockKeyService = mock(); + + mockPolicyService.policies$.mockReturnValue(of([])); + mockOrganizationService.organizations$.mockReturnValue(of([mockOrg])); + + await TestBed.configureTestingModule({ + imports: [AutoConfirmPolicyDialogComponent], + providers: [ + FormBuilder, + { provide: DIALOG_DATA, useValue: mockDialogData }, + { provide: AccountService, useValue: mockAccountService }, + { provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService }, + { provide: I18nService, useValue: mockI18nService }, + { provide: DialogRef, useValue: mockDialogRef }, + { provide: ToastService, useValue: mockToastService }, + { provide: KeyService, useValue: mockKeyService }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: PolicyService, useValue: mockPolicyService }, + { provide: Router, useValue: mockRouter }, + { provide: AutomaticUserConfirmationService, useValue: mockAutoConfirmService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(AutoConfirmPolicyDialogComponent, { + set: { template: "
" }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(AutoConfirmPolicyDialogComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("handleSubmit", () => { + beforeEach(() => { + // Mock the policyComponent + component.policyComponent = { + buildRequest: jest.fn().mockResolvedValue({ enabled: true, data: null }), + enabled: { value: true }, + setSingleOrgEnabled: jest.fn(), + } as any; + + mockAutoConfirmService.configuration$.mockReturnValue( + of({ enabled: false, showSetupDialog: true, showBrowserNotification: undefined }), + ); + mockAutoConfirmService.upsert.mockResolvedValue(undefined); + mockI18nService.t.mockReturnValue("Policy updated"); + }); + + it("should enable SingleOrg policy when it was not already enabled", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + // Call handleSubmit with singleOrgEnabled = false (meaning it needs to be enabled) + await component["handleSubmit"](false); + + // First call should be SingleOrg enable + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 1, + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should not enable SingleOrg policy when it was already enabled", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + // Call handleSubmit with singleOrgEnabled = true (meaning it's already enabled) + await component["handleSubmit"](true); + + // Should only call putPolicyVNext once (for AutoConfirm, not SingleOrg) + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith( + mockOrgId, + PolicyType.AutoConfirm, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should rollback SingleOrg policy when AutoConfirm fails and SingleOrg was enabled during action", async () => { + const autoConfirmError = new Error("AutoConfirm failed"); + + // First call (SingleOrg enable) succeeds, second call (AutoConfirm) fails, third call (SingleOrg rollback) succeeds + mockPolicyApiService.putPolicyVNext + .mockResolvedValueOnce({} as any) // SingleOrg enable + .mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails + .mockResolvedValueOnce({} as any); // SingleOrg rollback + + await expect(component["handleSubmit"](false)).rejects.toThrow("AutoConfirm failed"); + + // Verify: SingleOrg enabled, AutoConfirm attempted, SingleOrg rolled back + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(3); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 1, + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: true, data: null } }, + ); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 2, + mockOrgId, + PolicyType.AutoConfirm, + { policy: { enabled: true, data: null } }, + ); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 3, + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: false, data: null } }, + ); + }); + + it("should not rollback SingleOrg policy when AutoConfirm fails but SingleOrg was already enabled", async () => { + const autoConfirmError = new Error("AutoConfirm failed"); + + // AutoConfirm call fails (SingleOrg was already enabled, so no SingleOrg calls) + mockPolicyApiService.putPolicyVNext.mockRejectedValue(autoConfirmError); + + await expect(component["handleSubmit"](true)).rejects.toThrow("AutoConfirm failed"); + + // Verify only AutoConfirm was called (no SingleOrg enable/rollback) + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith( + mockOrgId, + PolicyType.AutoConfirm, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should keep both policies enabled when both submissions succeed", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + await component["handleSubmit"](false); + + // Verify two calls: SingleOrg enable and AutoConfirm enable + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(2); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 1, + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: true, data: null } }, + ); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 2, + mockOrgId, + PolicyType.AutoConfirm, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should re-throw the error after rollback", async () => { + const autoConfirmError = new Error("Network error"); + + mockPolicyApiService.putPolicyVNext + .mockResolvedValueOnce({} as any) // SingleOrg enable + .mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails + .mockResolvedValueOnce({} as any); // SingleOrg rollback + + await expect(component["handleSubmit"](false)).rejects.toThrow("Network error"); + }); + }); + + describe("setSingleOrgPolicy", () => { + it("should call putPolicyVNext with enabled: true when enabling", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + await component["setSingleOrgPolicy"](true); + + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith( + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should call putPolicyVNext with enabled: false when disabling", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + await component["setSingleOrgPolicy"](false); + + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith( + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: false, data: null } }, + ); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts index fbdeffc71bb..f0146225b8d 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts @@ -181,10 +181,21 @@ export class AutoConfirmPolicyDialogComponent } private async handleSubmit(singleOrgEnabled: boolean) { - if (!singleOrgEnabled) { - await this.submitSingleOrg(); + const enabledSingleOrgDuringAction = !singleOrgEnabled; + + if (enabledSingleOrgDuringAction) { + await this.setSingleOrgPolicy(true); + } + + try { + await this.submitAutoConfirm(); + } catch (error) { + // Roll back SingleOrg if we enabled it during this action + if (enabledSingleOrgDuringAction) { + await this.setSingleOrgPolicy(false); + } + throw error; } - await this.submitAutoConfirm(); } /** @@ -198,11 +209,9 @@ export class AutoConfirmPolicyDialogComponent const autoConfirmRequest = await this.policyComponent.buildRequest(); - await this.policyApiService.putPolicy( - this.data.organizationId, - this.data.policy.type, - autoConfirmRequest, - ); + await this.policyApiService.putPolicyVNext(this.data.organizationId, this.data.policy.type, { + policy: autoConfirmRequest, + }); const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); @@ -225,17 +234,15 @@ export class AutoConfirmPolicyDialogComponent } } - private async submitSingleOrg(): Promise { + private async setSingleOrgPolicy(enabled: boolean): Promise { const singleOrgRequest: PolicyRequest = { - enabled: true, + enabled, data: null, }; - await this.policyApiService.putPolicyVNext( - this.data.organizationId, - PolicyType.SingleOrg, - singleOrgRequest, - ); + await this.policyApiService.putPolicyVNext(this.data.organizationId, PolicyType.SingleOrg, { + policy: singleOrgRequest, + }); } private async openBrowserExtension() { diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html index e509692aba7..a2c510b78df 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html @@ -2,7 +2,7 @@ - {{ "editCollection" | i18n }} + {{ (dialogReadonly ? "viewCollection" : "editCollection") | i18n }} {{ collection.name }} @@ -63,7 +63,7 @@ - +
{{ "readOnlyCollectionAccess" | i18n }} diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts index 4f40ea701d2..2f9ddddd8cb 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -361,6 +361,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { return this.params.readonly === true; } + protected get accessTabLabel(): string { + return this.dialogReadonly + ? this.i18nService.t("viewAccess") + : this.i18nService.t("editAccess"); + } + protected async cancel() { this.close(CollectionDialogAction.Canceled); } diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html index ca1264829b9..0255e1a6a99 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html +++ b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html @@ -1,7 +1,7 @@
- - + +
{ expect(sut).not.toBeFalsy(); }); + /** + * @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties + * in the test setup above that are now un-used and can also be removed. + */ describe("setInitialPassword(...)", () => { // Mock function parameters let credentials: SetInitialPasswordCredentials; @@ -119,6 +123,8 @@ describe("WebSetInitialPasswordService", () => { orgSsoIdentifier: "orgSsoIdentifier", orgId: "orgId", resetPasswordAutoEnroll: false, + newPassword: "Test@Password123!", + salt: "user@example.com" as MasterPasswordSalt, }; userId = "userId" as UserId; userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; diff --git a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts index 0b8dba6c40e..a6a902ab847 100644 --- a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts +++ b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts @@ -56,6 +56,9 @@ export class WebSetInitialPasswordService ); } + /** + * @deprecated To be removed in PM-28143 + */ override async setInitialPassword( credentials: SetInitialPasswordCredentials, userType: SetInitialPasswordUserType, diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts index d13987f2e8b..fde0ea3a33f 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts @@ -166,5 +166,13 @@ describe("EmergencyViewDialogComponent", () => { expect(component["title"]).toBe("viewItemHeaderNote"); }); + + it("sets ssh key title", () => { + mockCipher.type = CipherType.SshKey; + + component["updateTitle"](); + + expect(component["title"]).toBe("viewItemHeaderSshKey"); + }); }); }); diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts index 62cfd95ecfa..86b75d27666 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts @@ -90,6 +90,9 @@ export class EmergencyViewDialogComponent { case CipherType.SecureNote: this.title = this.i18nService.t("viewItemHeaderNote"); break; + case CipherType.SshKey: + this.title = this.i18nService.t("viewItemHeaderSshKey"); + break; } } diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts index d93a5947445..d27e8ffecce 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts @@ -29,7 +29,7 @@ import { DialogRef, DialogService, FormFieldModule, - IconModule, + SvgModule, InputModule, LinkModule, ToastService, @@ -68,7 +68,7 @@ declare global { TypographyModule, CalloutModule, ButtonModule, - IconModule, + SvgModule, I18nPipe, AsyncActionsModule, JslibModule, diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts index b4c8ece92a7..2bb7fd4f368 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts @@ -21,7 +21,7 @@ import { DialogRef, DialogService, FormFieldModule, - IconModule, + SvgModule, InputModule, ToastService, TypographyModule, @@ -42,7 +42,7 @@ import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-bas InputModule, TypographyModule, ButtonModule, - IconModule, + SvgModule, I18nPipe, ReactiveFormsModule, AsyncActionsModule, diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts index 1402d6b8969..86b4e2ad939 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts @@ -24,7 +24,7 @@ import { DialogRef, DialogService, FormFieldModule, - IconModule, + SvgModule, InputModule, ToastService, TypographyModule, @@ -45,7 +45,7 @@ import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-bas CommonModule, DialogModule, FormFieldModule, - IconModule, + SvgModule, I18nPipe, InputModule, ReactiveFormsModule, diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.html b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.html index 305bb8e7f9c..a89aa498204 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.html +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.html @@ -16,7 +16,7 @@
- +

{{ "creatingPasskeyLoading" | i18n }}

{{ "creatingPasskeyLoadingInfo" | i18n }}

@@ -27,7 +27,7 @@ class="tw-flex tw-flex-col tw-items-center" >
- +

{{ "errorCreatingPasskey" | i18n }}

{{ "errorCreatingPasskeyInfo" | i18n }}

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 a392199e450..47436f6b7e4 100644 --- a/apps/web/src/app/billing/clients/account-billing.client.ts +++ b/apps/web/src/app/billing/clients/account-billing.client.ts @@ -3,8 +3,6 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response"; -import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { BitwardenSubscription } from "@bitwarden/subscription"; import { @@ -59,7 +57,7 @@ export class AccountBillingClient { upgradePremiumToOrganization = async ( organizationName: string, - organizationKey: EncString, + organizationKey: string, planTier: ProductTierType, cadence: SubscriptionCadence, billingAddress: Pick, @@ -68,7 +66,13 @@ export class AccountBillingClient { await this.apiService.send( "POST", path, - { organizationName, key: organizationKey, tier: planTier, cadence, billingAddress }, + { + organizationName, + key: organizationKey, + targetProductTierType: planTier, + cadence, + billingAddress, + }, true, false, ); diff --git a/apps/web/src/app/billing/clients/preview-invoice.client.ts b/apps/web/src/app/billing/clients/preview-invoice.client.ts index 65ed78315d2..16fb1ca0762 100644 --- a/apps/web/src/app/billing/clients/preview-invoice.client.ts +++ b/apps/web/src/app/billing/clients/preview-invoice.client.ts @@ -21,6 +21,8 @@ export class ProrationPreviewResponse extends BaseResponse { tax: number; total: number; credit: number; + newPlanProratedMonths: number; + newPlanProratedAmount: number; constructor(response: any) { super(response); @@ -28,6 +30,8 @@ export class ProrationPreviewResponse extends BaseResponse { this.tax = this.getResponseProperty("Tax"); this.total = this.getResponseProperty("Total"); this.credit = this.getResponseProperty("Credit"); + this.newPlanProratedMonths = this.getResponseProperty("NewPlanProratedMonths"); + this.newPlanProratedAmount = this.getResponseProperty("NewPlanProratedAmount"); } } 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 f060d29b377..2d0a4b465ed 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -57,12 +57,8 @@
- - {{ - (sub.subscription.periodEndDate | date: "MMM d, y") + - ", " + - (discountedSubscriptionAmount | currency: "$") - }} + + {{ sub.subscription.periodEndDate | date: "MMM d, y" }}
- - {{ - (sub.subscription.periodEndDate | date: "MMM d, y") + - ", " + - (subscriptionAmount | currency: "$") - }} + + {{ sub.subscription.periodEndDate | date: "MMM d, y" }}
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 4858deabec6..496ddb4ff9b 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 @@ -242,7 +242,7 @@
- +

{{ "billingManagedByProvider" | i18n: userOrg.providerName }}

{{ "billingContactProviderForAssistance" | i18n }}

diff --git a/apps/web/src/app/billing/organizations/subscription-hidden.component.ts b/apps/web/src/app/billing/organizations/subscription-hidden.component.ts index ef6e2dd0495..249cf999305 100644 --- a/apps/web/src/app/billing/organizations/subscription-hidden.component.ts +++ b/apps/web/src/app/billing/organizations/subscription-hidden.component.ts @@ -10,7 +10,7 @@ import { GearIcon } from "@bitwarden/assets/svg"; selector: "app-org-subscription-hidden", template: `
- +

{{ "billingManagedByProvider" | i18n: providerName }}

{{ "billingContactProviderForAssistance" | i18n }}

diff --git a/apps/web/src/app/billing/shared/sm-subscribe.component.html b/apps/web/src/app/billing/shared/sm-subscribe.component.html index 6cdaeb9476d..70990d2ee4c 100644 --- a/apps/web/src/app/billing/shared/sm-subscribe.component.html +++ b/apps/web/src/app/billing/shared/sm-subscribe.component.html @@ -2,7 +2,7 @@

{{ "moreFromBitwarden" | i18n }}

- +
-
-

{{ "reportError" | i18n }}...

- - - {{ "breachUsernameNotFound" | i18n: checkedUsername }} - - - {{ "breachUsernameFound" | i18n: checkedUsername : breachedAccounts.length }} - -
    -
  • -
    - -
    -
    -

    {{ a.title }}

    -

    -

    {{ "compromisedData" | i18n }}:

    -
      -
    • {{ d }}
    • -
    -
    -
    -
    -
    {{ "website" | i18n }}
    -
    {{ a.domain }}
    -
    {{ "affectedUsers" | i18n }}
    -
    {{ a.pwnCount | number }}
    -
    {{ "breachOccurred" | i18n }}
    -
    {{ a.breachDate | date: "mediumDate" }}
    -
    {{ "breachReported" | i18n }}
    -
    {{ a.addedDate | date: "mediumDate" }}
    -
    -
    -
  • -
-
-
+ @if (!loading && checkedUsername) { +
+ @if (error) { +

{{ "reportError" | i18n }}...

+ } @else { + @if (!breachedAccounts.length) { + + {{ "breachUsernameNotFound" | i18n: checkedUsername }} + + } @else { + + {{ "breachUsernameFound" | i18n: checkedUsername : breachedAccounts.length }} + +
    + @for (a of breachedAccounts; track a) { +
  • +
    + +
    +
    +

    {{ a.title }}

    +

    +

    {{ "compromisedData" | i18n }}:

    +
      + @for (d of a.dataClasses; track d) { +
    • {{ d }}
    • + } +
    +
    +
    +
    +
    {{ "website" | i18n }}
    +
    {{ a.domain }}
    +
    {{ "affectedUsers" | i18n }}
    +
    {{ a.pwnCount | number }}
    +
    {{ "breachOccurred" | i18n }}
    +
    {{ a.breachDate | date: "mediumDate" }}
    +
    {{ "breachReported" | i18n }}
    +
    {{ a.addedDate | date: "mediumDate" }}
    +
    +
    +
  • + } +
+ } + } +
+ } diff --git a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts index d098be56663..f775ed84ede 100644 --- a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts @@ -46,8 +46,11 @@ export abstract class CipherReportComponent implements OnDestroy { organizations: Organization[] = []; organizations$: Observable; + readonly maxItemsToSwitchToChipSelect = 5; filterStatus: any = [0]; showFilterToggle: boolean = false; + selectedFilterChip: string = "0"; + chipSelectOptions: { label: string; value: string }[] = []; vaultMsg: string = "vault"; currentFilterStatus: number | string = 0; protected filterOrgStatus$ = new BehaviorSubject(0); @@ -190,6 +193,7 @@ export abstract class CipherReportComponent implements OnDestroy { formConfig, activeCollectionId, disableForm, + isAdminConsoleAction: true, }); const result = await lastValueFrom(this.vaultItemDialogRef.closed); @@ -288,6 +292,15 @@ export abstract class CipherReportComponent implements OnDestroy { return await this.cipherService.getAllDecrypted(activeUserId); } + protected canDisplayToggleGroup(): boolean { + return this.filterStatus.length <= this.maxItemsToSwitchToChipSelect; + } + + async filterOrgToggleChipSelect(filterId: string | null) { + const selectedFilterId = filterId ?? 0; + await this.filterOrgToggle(selectedFilterId); + } + protected filterCiphersByOrg(ciphersList: CipherView[]) { this.allCiphers = [...ciphersList]; @@ -309,5 +322,22 @@ export abstract class CipherReportComponent implements OnDestroy { this.showFilterToggle = false; this.vaultMsg = "vault"; } + + this.chipSelectOptions = this.setupChipSelectOptions(this.filterStatus); + } + + private setupChipSelectOptions(filters: string[]) { + const options = filters.map((filterId: string, index: number) => { + const name = this.getName(filterId); + const count = this.getCount(filterId); + const labelSuffix = count != null ? ` (${count})` : ""; + + return { + label: name + labelSuffix, + value: filterId, + }; + }); + + return options; } } diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html index fcdb3f6ca64..ba118ea6663 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html @@ -5,95 +5,119 @@ -
- - {{ "noExposedPasswords" | i18n }} - - - - {{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - - - - {{ "name" | i18n }} - - {{ "owner" | i18n }} - - - {{ "timesExposed" | i18n }} - - - - - - - - - - {{ row.name }} - - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
- {{ row.subTitle }} - - - + @if (!ciphers.length) { + + {{ "noExposedPasswords" | i18n }} + + } @else { + + {{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - {{ "exposedXTimes" | i18n: (row.exposedXTimes | number) }} - - -
-
-
-
+ @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + + + {{ "name" | i18n }} + @if (!isAdminConsoleActive) { + + {{ "owner" | i18n }} + + } + + {{ "timesExposed" | i18n }} + + + + + + + + @if (!organization || canManageCipher(row)) { + + {{ row.name }} + + } @else { + {{ row.name }} + } + @if (!organization && row.organizationId) { + + {{ "shared" | i18n }} + } + @if (row.hasAttachments) { + + {{ "attachments" | i18n }} + } +
+ {{ row.subTitle }} + + @if (!isAdminConsoleActive) { + + @if (!organization) { + + + } + + } + + + {{ "exposedXTimes" | i18n: (row.exposedXTimes | number) }} + + +
+
+ } +
+ } diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html index 9a99a55b77b..4999d572969 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html @@ -2,104 +2,124 @@

{{ "inactive2faReportDesc" | i18n }}

-
- - {{ "loading" | i18n }} -
-
- - {{ "noInactive2fa" | i18n }} - - - - {{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - - - - - - - - - {{ row.name }} - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
- {{ row.subTitle }} - - - + + {{ "loading" | i18n }} +
+ } @else { +
+ @if (!ciphers.length) { + + {{ "noInactive2fa" | i18n }} + + } @else { + + {{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - {{ "instructions" | i18n }} - - - - -
+ @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + @if (!isAdminConsoleActive) { + + + {{ "name" | i18n }} + {{ "owner" | i18n }} + + + } + + + + + + @if (!organization || canManageCipher(row)) { + + {{ row.name }} + + } @else { + + {{ row.name }} + + } + @if (!organization && row.organizationId) { + + + {{ "shared" | i18n }} + + } + @if (row.hasAttachments) { + + + {{ "attachments" | i18n }} + + } +
+ {{ row.subTitle }} + + + @if (!organization) { + + } + + + @if (cipherDocs.has(row.id)) { + + {{ "instructions" | i18n }} + } + +
+
+ } +
+ } diff --git a/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts index 1d3d8d71f5a..6c81cbd9986 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts @@ -16,7 +16,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { PasswordRepromptService, CipherFormConfigService, @@ -45,7 +45,7 @@ import { ExposedPasswordsReportComponent as BaseExposedPasswordsReportComponent RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class ExposedPasswordsReportComponent extends BaseExposedPasswordsReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts index 23d1330dad7..6b93b289df9 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts @@ -11,7 +11,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService, @@ -39,7 +39,7 @@ import { InactiveTwoFactorReportComponent as BaseInactiveTwoFactorReportComponen RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class InactiveTwoFactorReportComponent extends BaseInactiveTwoFactorReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts index 599774d5515..0ae9ecad0cb 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts @@ -15,7 +15,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService, @@ -44,7 +44,7 @@ import { ReusedPasswordsReportComponent as BaseReusedPasswordsReportComponent } RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class ReusedPasswordsReportComponent extends BaseReusedPasswordsReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts index 6bf741b86eb..0b7cd3bfe7c 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts @@ -15,7 +15,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService, @@ -44,7 +44,7 @@ import { UnsecuredWebsitesReportComponent as BaseUnsecuredWebsitesReportComponen RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class UnsecuredWebsitesReportComponent extends BaseUnsecuredWebsitesReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts index 6780b65931c..411295ceb2a 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts @@ -16,7 +16,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService, @@ -45,7 +45,7 @@ import { WeakPasswordsReportComponent as BaseWeakPasswordsReportComponent } from RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class WeakPasswordsReportComponent extends BaseWeakPasswordsReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/reports-home.component.html b/apps/web/src/app/dirt/reports/pages/reports-home.component.html index 9101933bc40..ee3caae4212 100644 --- a/apps/web/src/app/dirt/reports/pages/reports-home.component.html +++ b/apps/web/src/app/dirt/reports/pages/reports-home.component.html @@ -3,5 +3,5 @@

{{ "reportsDesc" | i18n }}

- +
diff --git a/apps/web/src/app/dirt/reports/pages/reports-home.component.ts b/apps/web/src/app/dirt/reports/pages/reports-home.component.ts index a0e3a73aa3f..5dd7f1d3ec0 100644 --- a/apps/web/src/app/dirt/reports/pages/reports-home.component.ts +++ b/apps/web/src/app/dirt/reports/pages/reports-home.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, OnInit, signal } from "@angular/core"; import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -9,15 +9,14 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { reports, ReportType } from "../reports"; import { ReportEntry, ReportVariant } from "../shared"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "app-reports-home", templateUrl: "reports-home.component.html", standalone: false, }) export class ReportsHomeComponent implements OnInit { - reports: ReportEntry[]; + readonly reports = signal([]); constructor( private billingAccountProfileStateService: BillingAccountProfileStateService, @@ -33,7 +32,7 @@ export class ReportsHomeComponent implements OnInit { ? ReportVariant.Enabled : ReportVariant.RequiresPremium; - this.reports = [ + this.reports.set([ { ...reports[ReportType.ExposedPasswords], variant: reportRequiresPremium, @@ -58,6 +57,6 @@ export class ReportsHomeComponent implements OnInit { ...reports[ReportType.DataBreach], variant: ReportVariant.Enabled, }, - ]; + ]); } } diff --git a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html index d09dfa81fd4..f08af8bda01 100644 --- a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html @@ -2,100 +2,115 @@

{{ "reusedPasswordsReportDesc" | i18n }}

-
- - {{ "loading" | i18n }} -
-
- - {{ "noReusedPasswords" | i18n }} - - - - {{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - - - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - {{ "timesReused" | i18n }} - - - - - - - - {{ row.name }} - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
- {{ row.subTitle }} - - - + + {{ "loading" | i18n }} +
+ } @else { +
+ @if (!ciphers.length) { + + {{ "noReusedPasswords" | i18n }} + + } @else { + + {{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - {{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }} - - - - - -
+ @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + @if (!isAdminConsoleActive) { + + + {{ "name" | i18n }} + {{ "owner" | i18n }} + {{ "timesReused" | i18n }} + + } + + + + + + @if (!organization || canManageCipher(row)) { + {{ row.name }} + } @else { + {{ row.name }} + } + @if (!organization && row.organizationId) { + + {{ "shared" | i18n }} + } + @if (row.hasAttachments) { + + {{ "attachments" | i18n }} + } +
+ {{ row.subTitle }} + + + @if (!organization) { + + + } + + + + {{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }} + + +
+
+ } +
+ } diff --git a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html index cc7537333ad..810c1e384b0 100644 --- a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html @@ -2,93 +2,109 @@

{{ "unsecuredWebsitesReportDesc" | i18n }}

-
- - {{ "loading" | i18n }} -
-
- - {{ "noUnsecuredWebsites" | i18n }} - - - - {{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - - - - - - - - {{ row.name }} - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
- {{ row.subTitle }} - - - + + {{ "loading" | i18n }} +
+ } @else { +
+ @if (!ciphers.length) { + + {{ "noUnsecuredWebsites" | i18n }} + + } @else { + + {{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - -
+ @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + @if (!isAdminConsoleActive) { + + + {{ "name" | i18n }} + {{ "owner" | i18n }} + + } + + + + + + @if (!organization || canManageCipher(row)) { + {{ row.name }} + } @else { + {{ row.name }} + } + @if (!organization && row.organizationId) { + + {{ "shared" | i18n }} + } + @if (row.hasAttachments) { + + {{ "attachments" | i18n }} + } +
+ {{ row.subTitle }} + + + @if (!organization) { + + + } + +
+
+ } +
+ } diff --git a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html index 92d56c1c7a3..5f047316a29 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html @@ -2,102 +2,123 @@

{{ "weakPasswordsReportDesc" | i18n }}

-
- - {{ "loading" | i18n }} -
-
- - {{ "noWeakPasswords" | i18n }} - - - - {{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - - - - {{ "name" | i18n }} - - {{ "owner" | i18n }} - - - {{ "weakness" | i18n }} - - - - - - - - - {{ row.name }} - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
- {{ row.subTitle }} - - - + + {{ "loading" | i18n }} +
+ } @else { +
+ @if (!ciphers.length) { + + {{ "noWeakPasswords" | i18n }} + + } @else { + + {{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - {{ row.reportValue.label | i18n }} - - - - - -
+ @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + + + {{ "name" | i18n }} + @if (!isAdminConsoleActive) { + + {{ "owner" | i18n }} + + } + + {{ "weakness" | i18n }} + + + + + + + + @if (!organization || canManageCipher(row)) { + {{ row.name }} + } @else { + {{ row.name }} + } + @if (!organization && row.organizationId) { + + {{ "shared" | i18n }} + } + @if (row.hasAttachments) { + + {{ "attachments" | i18n }} + } +
+ {{ row.subTitle }} + + @if (!isAdminConsoleActive) { + + @if (!organization) { + + + } + + } + + + {{ row.reportValue.label | i18n }} + + +
+
+ } +
+ } diff --git a/apps/web/src/app/dirt/reports/reports.module.ts b/apps/web/src/app/dirt/reports/reports.module.ts index 5648b40982a..4fc152917f4 100644 --- a/apps/web/src/app/dirt/reports/reports.module.ts +++ b/apps/web/src/app/dirt/reports/reports.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { ChipSelectComponent } from "@bitwarden/components"; import { CipherFormConfigService, DefaultCipherFormConfigService, @@ -34,6 +35,7 @@ import { ReportsSharedModule } from "./shared"; OrganizationBadgeModule, PipesModule, HeaderModule, + ChipSelectComponent, ], declarations: [ BreachReportComponent, diff --git a/apps/web/src/app/dirt/reports/shared/models/report-entry.ts b/apps/web/src/app/dirt/reports/shared/models/report-entry.ts index fd1e57991fb..63edbc63f92 100644 --- a/apps/web/src/app/dirt/reports/shared/models/report-entry.ts +++ b/apps/web/src/app/dirt/reports/shared/models/report-entry.ts @@ -1,4 +1,4 @@ -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { ReportVariant } from "./report-variant"; @@ -6,6 +6,6 @@ export type ReportEntry = { title: string; description: string; route: string; - icon: Icon; + icon: BitSvg; variant: ReportVariant; }; diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html index f0318028e60..ab0fe0c28ac 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html @@ -8,7 +8,7 @@ [ngClass]="{ 'tw-grayscale': disabled }" >
- +
diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts index 87c005ea46b..2f4934381b9 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { Component, Input } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { ReportVariant } from "../models/report-variant"; @@ -25,7 +25,7 @@ export class ReportCardComponent { @Input() route: string; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() icon: Icon; + @Input() icon: BitSvg; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() variant: ReportVariant; diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts index 93ea79c8418..4f442dc9380 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts @@ -14,7 +14,7 @@ import { BaseCardComponent, CardContentComponent, I18nMockService, - IconModule, + SvgModule, } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; @@ -31,7 +31,7 @@ export default { JslibModule, BadgeModule, CardContentComponent, - IconModule, + SvgModule, RouterTestingModule, PremiumBadgeComponent, BaseCardComponent, diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html index 2a03bf78dd4..4726eb5c42f 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html @@ -1,13 +1,15 @@
-
- -
+ @for (report of reports(); track report) { +
+ +
+ }
diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.ts b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.ts index 509e2f3b872..095484de0ce 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.ts +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.ts @@ -1,18 +1,13 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { ReportEntry } from "../models/report-entry"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "app-report-list", templateUrl: "report-list.component.html", standalone: false, }) export class ReportListComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() reports: ReportEntry[]; + readonly reports = input([]); } diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts index 5a95e332816..9686644bd74 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts @@ -12,7 +12,7 @@ import { BadgeModule, BaseCardComponent, CardContentComponent, - IconModule, + SvgModule, } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; @@ -31,7 +31,7 @@ export default { JslibModule, BadgeModule, RouterTestingModule, - IconModule, + SvgModule, PremiumBadgeComponent, CardContentComponent, BaseCardComponent, diff --git a/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts b/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts index 9e993259830..a8e1830971e 100644 --- a/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts +++ b/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts @@ -5,6 +5,7 @@ import { firstValueFrom, of } from "rxjs"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { UserId } from "@bitwarden/common/types/guid"; import { BiometricsStatus } from "@bitwarden/key-management"; +import { WebAuthnPrfUnlockService } from "@bitwarden/key-management-ui"; import { WebLockComponentService } from "./web-lock-component.service"; @@ -12,9 +13,11 @@ describe("WebLockComponentService", () => { let service: WebLockComponentService; let userDecryptionOptionsService: MockProxy; + let webAuthnPrfUnlockService: MockProxy; beforeEach(() => { userDecryptionOptionsService = mock(); + webAuthnPrfUnlockService = mock(); TestBed.configureTestingModule({ providers: [ @@ -23,6 +26,10 @@ describe("WebLockComponentService", () => { provide: UserDecryptionOptionsServiceAbstraction, useValue: userDecryptionOptionsService, }, + { + provide: WebAuthnPrfUnlockService, + useValue: webAuthnPrfUnlockService, + }, ], }); @@ -91,6 +98,7 @@ describe("WebLockComponentService", () => { userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValueOnce( of(userDecryptionOptions), ); + webAuthnPrfUnlockService.isPrfUnlockAvailable.mockResolvedValue(false); const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); @@ -105,6 +113,9 @@ describe("WebLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported, }, + prf: { + enabled: false, + }, }); }); }); diff --git a/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts b/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts index ea038ca2c67..0451aa08689 100644 --- a/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts +++ b/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts @@ -1,16 +1,18 @@ import { inject } from "@angular/core"; -import { map, Observable } from "rxjs"; +import { combineLatest, defer, map, Observable } from "rxjs"; -import { - UserDecryptionOptions, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { UserId } from "@bitwarden/common/types/guid"; import { BiometricsStatus } from "@bitwarden/key-management"; -import { LockComponentService, UnlockOptions } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + UnlockOptions, + WebAuthnPrfUnlockService, +} from "@bitwarden/key-management-ui"; export class WebLockComponentService implements LockComponentService { private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction); + private readonly webAuthnPrfUnlockService = inject(WebAuthnPrfUnlockService); constructor() {} @@ -43,8 +45,14 @@ export class WebLockComponentService implements LockComponentService { } getAvailableUnlockOptions$(userId: UserId): Observable { - return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId)?.pipe( - map((userDecryptionOptions: UserDecryptionOptions) => { + return combineLatest([ + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + defer(async () => { + const available = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(userId); + return { available }; + }), + ]).pipe( + map(([userDecryptionOptions, prfUnlockInfo]) => { const unlockOpts: UnlockOptions = { masterPassword: { enabled: userDecryptionOptions.hasMasterPassword, @@ -56,6 +64,9 @@ export class WebLockComponentService implements LockComponentService { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported, }, + prf: { + enabled: prfUnlockInfo.available, + }, }; return unlockOpts; }), diff --git a/apps/web/src/app/layouts/header/web-header.stories.ts b/apps/web/src/app/layouts/header/web-header.stories.ts index 88c98f01e6c..3b3b28b8e45 100644 --- a/apps/web/src/app/layouts/header/web-header.stories.ts +++ b/apps/web/src/app/layouts/header/web-header.stories.ts @@ -24,7 +24,7 @@ import { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, @@ -94,7 +94,7 @@ export default { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, TabsModule, diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 90207f59ad4..33bce661c65 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -16,7 +16,7 @@ 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 { SyncService } from "@bitwarden/common/platform/sync"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { BillingFreeFamiliesNavItemComponent } from "../billing/shared/billing-free-families-nav-item.component"; @@ -32,7 +32,7 @@ import { WebLayoutModule } from "./web-layout.module"; RouterModule, JslibModule, WebLayoutModule, - IconModule, + SvgModule, BillingFreeFamiliesNavItemComponent, ], }) diff --git a/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts b/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts index 6d051a91f7e..6873700e2bc 100644 --- a/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts +++ b/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts @@ -4,7 +4,7 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an import { delay, of, startWith } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { LinkModule, IconModule, ProgressModule } from "@bitwarden/components"; +import { LinkModule, SvgModule, ProgressModule } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../core/tests"; @@ -16,7 +16,7 @@ export default { component: OnboardingComponent, decorators: [ moduleMetadata({ - imports: [JslibModule, RouterModule, LinkModule, IconModule, ProgressModule], + imports: [JslibModule, RouterModule, LinkModule, SvgModule, ProgressModule], declarations: [OnboardingTaskComponent], }), applicationConfig({ diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index 6012e4867e1..b83555fd84e 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -18,7 +18,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, MultiSelectModule, @@ -63,7 +63,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, MultiSelectModule, @@ -99,7 +99,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, MultiSelectModule, 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 b86933410b8..6cda4cf4d7d 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,4 +1,4 @@ -@switch (viewState) { +@switch (viewState()) { @case ("auth") { } @@ -6,6 +6,7 @@ (SendViewState.Auth); id: string; key: string; + sendAccessToken: SendAccessToken | null = null; sendAccessResponse: SendAccessResponse | null = null; sendAccessRequest: SendAccessRequest = new SendAccessRequest(); - constructor(private route: ActivatedRoute) {} + constructor( + private route: ActivatedRoute, + private destroyRef: DestroyRef, + ) {} - async ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.params.subscribe(async (params) => { + ngOnInit() { + this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { this.id = params.sendId; this.key = params.key; - - if (this.id && this.key) { - this.viewState = SendViewState.View; - this.sendAccessResponse = null; - this.sendAccessRequest = new SendAccessRequest(); - } }); } onAuthRequired() { - this.viewState = SendViewState.Auth; + this.viewState.set(SendViewState.Auth); } - onAccessGranted(event: { response: SendAccessResponse; request: SendAccessRequest }) { + onAccessGranted(event: { + response?: SendAccessResponse; + request?: SendAccessRequest; + accessToken?: SendAccessToken; + }) { this.sendAccessResponse = event.response; this.sendAccessRequest = event.request; - this.viewState = SendViewState.View; + this.sendAccessToken = event.accessToken; + this.viewState.set(SendViewState.View); } } diff --git a/apps/web/src/app/tools/send/send-access/send-access-email.component.html b/apps/web/src/app/tools/send/send-access/send-access-email.component.html new file mode 100644 index 00000000000..ee5a03670bb --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-access-email.component.html @@ -0,0 +1,35 @@ +@if (!enterOtp()) { + + {{ "email" | i18n }} + + +
+ +
+} @else { + + {{ "verificationCode" | i18n }} + + +
+ +
+} diff --git a/apps/web/src/app/tools/send/send-access/send-access-email.component.ts b/apps/web/src/app/tools/send/send-access/send-access-email.component.ts new file mode 100644 index 00000000000..b1374cd6c66 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-access-email.component.ts @@ -0,0 +1,35 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; + +import { SharedModule } from "../../../shared"; + +@Component({ + selector: "app-send-access-email", + templateUrl: "send-access-email.component.html", + imports: [SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendAccessEmailComponent implements OnInit, OnDestroy { + protected readonly formGroup = input.required(); + protected readonly enterOtp = input.required(); + protected email: FormControl; + protected otp: FormControl; + + readonly loading = input.required(); + + constructor() {} + + ngOnInit() { + this.email = new FormControl("", Validators.required); + this.otp = new FormControl("", Validators.required); + this.formGroup().addControl("email", this.email); + this.formGroup().addControl("otp", this.otp); + } + + ngOnDestroy() { + this.formGroup().removeControl("email"); + this.formGroup().removeControl("otp"); + } +} diff --git a/apps/web/src/app/tools/send/send-access/send-access-file.component.html b/apps/web/src/app/tools/send/send-access/send-access-file.component.html index 8cbe6a975ef..4088b3a7034 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-file.component.html +++ b/apps/web/src/app/tools/send/send-access/send-access-file.component.html @@ -1,5 +1,5 @@ -

{{ send.file.fileName }}

+

{{ send().file.fileName }}

diff --git a/apps/web/src/app/tools/send/send-access/send-access-file.component.ts b/apps/web/src/app/tools/send/send-access/send-access-file.component.ts index dc7689f011a..bb45e83d110 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-file.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-file.component.ts @@ -1,8 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, Input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; +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 { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -15,40 +18,39 @@ import { ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send-access-file", templateUrl: "send-access-file.component.html", imports: [SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendAccessFileComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() send: SendAccessView; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() decKey: SymmetricCryptoKey; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() accessRequest: SendAccessRequest; + readonly send = input(null); + readonly decKey = input(null); + readonly accessRequest = input(null); + readonly accessToken = input(null); + constructor( private i18nService: I18nService, private toastService: ToastService, private encryptService: EncryptService, private fileDownloadService: FileDownloadService, private sendApiService: SendApiService, + private configService: ConfigService, ) {} protected download = async () => { - if (this.send == null || this.decKey == null) { + const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + const accessToken = this.accessToken(); + const accessRequest = this.accessRequest(); + const authMissing = (sendEmailOtp && !accessToken) || (!sendEmailOtp && !accessRequest); + if (this.send() == null || this.decKey() == null || authMissing) { return; } - const downloadData = await this.sendApiService.getSendFileDownloadData( - this.send, - this.accessRequest, - ); + const downloadData = sendEmailOtp + ? await this.sendApiService.getSendFileDownloadDataV2(this.send(), accessToken) + : await this.sendApiService.getSendFileDownloadData(this.send(), accessRequest); if (Utils.isNullOrWhitespace(downloadData.url)) { this.toastService.showToast({ @@ -71,9 +73,9 @@ export class SendAccessFileComponent { try { const encBuf = await EncArrayBuffer.fromResponse(response); - const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey); + const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey()); this.fileDownloadService.download({ - fileName: this.send.file.fileName, + fileName: this.send().file.fileName, blobData: decBuf, downloadMethod: "save", }); diff --git a/apps/web/src/app/tools/send/send-access/send-access-password.component.html b/apps/web/src/app/tools/send/send-access/send-access-password.component.html index 8bb2c306010..deca7ad3d24 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-password.component.html +++ b/apps/web/src/app/tools/send/send-access/send-access-password.component.html @@ -1,28 +1,19 @@

{{ "sendProtectedPassword" | i18n }}

{{ "sendProtectedPasswordDontKnow" | i18n }}

-
- - {{ "password" | i18n }} - - - -
- -
+ + {{ "password" | i18n }} + + + +
+
diff --git a/apps/web/src/app/tools/send/send-access/send-access-password.component.ts b/apps/web/src/app/tools/send/send-access/send-access-password.component.ts index 34b183be10e..b2ee222ae86 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-password.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-password.component.ts @@ -1,43 +1,30 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { Subject, takeUntil } from "rxjs"; +import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; import { SharedModule } from "../../../shared"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send-access-password", templateUrl: "send-access-password.component.html", imports: [SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendAccessPasswordComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); - protected formGroup = this.formBuilder.group({ - password: ["", [Validators.required]], - }); + protected readonly formGroup = input.required(); + protected password: FormControl; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() loading: boolean; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref - @Output() setPasswordEvent = new EventEmitter(); + readonly loading = input.required(); - constructor(private formBuilder: FormBuilder) {} + constructor() {} - async ngOnInit() { - this.formGroup.controls.password.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe((val) => { - this.setPasswordEvent.emit(val); - }); + ngOnInit() { + this.password = new FormControl("", Validators.required); + this.formGroup().addControl("password", this.password); } ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); + this.formGroup().removeControl("password"); } } 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 index 21a6de50ba8..c3e90cea4ea 100644 --- 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 @@ -1,14 +1,38 @@ -
-
-

{{ "sendAccessUnavailable" | i18n }}

+@if (loading()) { +
+ + {{ "loading" | i18n }}
-
-

{{ "unexpectedErrorSend" | i18n }}

-
- - +} + + @if (error()) { +
+

{{ "unexpectedErrorSend" | i18n }}

+
+ } + @if (unavailable()) { +
+

{{ "sendAccessUnavailable" | i18n }}

+
+ } @else { + @switch (sendAuthType()) { + @case (authType.Password) { + + } + @case (authType.Email) { + + } + } + } 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 index b360044a8b6..9ed8106ad40 100644 --- 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 @@ -1,86 +1,210 @@ -import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, OnInit, output, signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; +import { + emailAndOtpRequired, + emailRequired, + otpInvalid, + passwordHashB64Invalid, + passwordHashB64Required, + SendAccessDomainCredentials, + SendAccessToken, + SendHashedPasswordB64, + sendIdInvalid, + SendOtp, + SendTokenService, +} from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +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 { 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 { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; +import { SendAccessEmailComponent } from "./send-access-email.component"; import { SendAccessPasswordComponent } from "./send-access-password.component"; @Component({ selector: "app-send-auth", templateUrl: "send-auth.component.html", - imports: [SendAccessPasswordComponent, SharedModule], + imports: [SendAccessPasswordComponent, SendAccessEmailComponent, SharedModule], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SendAuthComponent { - readonly id = input.required(); - readonly key = input.required(); +export class SendAuthComponent implements OnInit { + protected readonly id = input.required(); + protected readonly key = input.required(); - accessGranted = output<{ - response: SendAccessResponse; - request: SendAccessRequest; + protected accessGranted = output<{ + response?: SendAccessResponse; + request?: SendAccessRequest; + accessToken?: SendAccessToken; }>(); - loading = false; - error = false; - unavailable = false; - password?: string; + authType = AuthType; - private accessRequest!: SendAccessRequest; + private expiredAuthAttempts = 0; + + readonly loading = signal(false); + readonly error = signal(false); + readonly unavailable = signal(false); + readonly sendAuthType = signal(AuthType.None); + readonly enterOtp = signal(false); + + sendAccessForm = this.formBuilder.group<{ password?: string; email?: string; otp?: string }>({}); constructor( private cryptoFunctionService: CryptoFunctionService, private sendApiService: SendApiService, private toastService: ToastService, private i18nService: I18nService, + private formBuilder: FormBuilder, + private configService: ConfigService, + private sendTokenService: SendTokenService, ) {} - async onSubmit(password: string) { - this.password = password; - this.loading = true; - this.error = false; - this.unavailable = false; + ngOnInit() { + void this.onSubmit(); + } + async onSubmit() { + this.loading.set(true); + this.unavailable.set(false); + this.error.set(false); + const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + if (sendEmailOtp) { + await this.attemptV2Access(); + } else { + await this.attemptV1Access(); + } + this.loading.set(false); + } + + private async attemptV1Access() { 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 }); + const accessRequest = new SendAccessRequest(); + if (this.sendAuthType() === AuthType.Password) { + const password = this.sendAccessForm.value.password; + if (password == null) { + return; + } + accessRequest.password = await this.getPasswordHashB64(password, this.key()); + } + const sendResponse = await this.sendApiService.postSendAccess(this.id(), accessRequest); + this.accessGranted.emit({ request: accessRequest, response: sendResponse }); } catch (e) { if (e instanceof ErrorResponse) { - if (e.statusCode === 404) { - this.unavailable = true; - } else if (e.statusCode === 400) { + if (e.statusCode === 401) { + this.sendAuthType.set(AuthType.Password); + } else if (e.statusCode === 404) { + this.unavailable.set(true); + } else { + this.error.set(true); this.toastService.showToast({ variant: "error", title: this.i18nService.t("errorOccurred"), message: e.message, }); - } else { - this.error = true; } } else { - this.error = true; + this.error.set(true); } - } finally { - this.loading = false; } } + + private async attemptV2Access(): Promise { + let sendAccessCreds: SendAccessDomainCredentials | null = null; + if (this.sendAuthType() === AuthType.Email) { + const email = this.sendAccessForm.value.email; + if (email == null) { + return; + } + if (!this.enterOtp()) { + sendAccessCreds = { kind: "email", email }; + } else { + const otp = this.sendAccessForm.value.otp as SendOtp; + if (otp == null) { + return; + } + sendAccessCreds = { kind: "email_otp", email, otp }; + } + } else if (this.sendAuthType() === AuthType.Password) { + const password = this.sendAccessForm.value.password; + if (password == null) { + return; + } + const passwordHashB64 = await this.getPasswordHashB64(password, this.key()); + sendAccessCreds = { kind: "password", passwordHashB64 }; + } + const response = !sendAccessCreds + ? await firstValueFrom(this.sendTokenService.tryGetSendAccessToken$(this.id())) + : await firstValueFrom(this.sendTokenService.getSendAccessToken$(this.id(), sendAccessCreds)); + if (response instanceof SendAccessToken) { + this.expiredAuthAttempts = 0; + this.accessGranted.emit({ accessToken: response }); + } else if (response.kind === "expired") { + if (this.expiredAuthAttempts > 2) { + return; + } + this.expiredAuthAttempts++; + await this.attemptV2Access(); + } else if (response.kind === "expected_server") { + this.expiredAuthAttempts = 0; + if (emailRequired(response.error)) { + this.sendAuthType.set(AuthType.Email); + } else if (emailAndOtpRequired(response.error)) { + this.enterOtp.set(true); + } else if (otpInvalid(response.error)) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidVerificationCode"), + }); + } else if (passwordHashB64Required(response.error)) { + this.sendAuthType.set(AuthType.Password); + } else if (passwordHashB64Invalid(response.error)) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidSendPassword"), + }); + } else if (sendIdInvalid(response.error)) { + this.unavailable.set(true); + } else { + this.error.set(true); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: response.error.error_description ?? "", + }); + } + } else { + this.expiredAuthAttempts = 0; + this.error.set(true); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: response.error, + }); + } + } + + private async getPasswordHashB64(password: string, key: string) { + const keyArray = Utils.fromUrlB64ToArray(key); + const passwordHash = await this.cryptoFunctionService.pbkdf2( + password, + keyArray, + "sha256", + SEND_KDF_ITERATIONS, + ); + return Utils.fromBufferToB64(passwordHash) as SendHashedPasswordB64; + } } 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 index dd0b770b261..3536499ddad 100644 --- 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 @@ -1,41 +1,13 @@ - - {{ "viewSendHiddenEmailWarning" | i18n }} - {{ - "learnMore" | i18n - }}. - +@if (hideEmail()) { + + {{ "viewSendHiddenEmailWarning" | i18n }} + {{ + "learnMore" | i18n + }} + +} - -
-

{{ "sendAccessUnavailable" | i18n }}

-
-
-

{{ "unexpectedErrorSend" | i18n }}

-
-
-

- {{ send.name }} -

-
- - - - - - - - -

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

-
-
- +@if (loading()) {
{{ "loading" | i18n }}
-
+} @else { + @if (unavailable()) { +
+

{{ "sendAccessUnavailable" | i18n }}

+
+ } + @if (error()) { +
+

{{ "unexpectedErrorSend" | i18n }}

+
+ } + @if (send()) { +
+

+ {{ send().name }} +

+
+ @switch (send().type) { + @case (sendType.Text) { + + } + @case (sendType.File) { + + } + } + @if (expirationDate()) { +

Expires: {{ expirationDate() | date: "medium" }}

+ } +
+ } +} diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.ts b/apps/web/src/app/tools/send/send-access/send-view.component.ts index 060dc1958b1..1ab9a121ace 100644 --- a/apps/web/src/app/tools/send/send-access/send-view.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-view.component.ts @@ -1,13 +1,17 @@ import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, + computed, input, OnInit, output, + signal, } from "@angular/core"; +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +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 { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; @@ -34,17 +38,25 @@ import { SendAccessTextComponent } from "./send-access-text.component"; export class SendViewComponent implements OnInit { readonly id = input.required(); readonly key = input.required(); + readonly accessToken = input(null); readonly sendResponse = input(null); readonly accessRequest = input(new SendAccessRequest()); authRequired = output(); - send: SendAccessView | null = null; + readonly send = signal(null); + readonly expirationDate = computed(() => this.send()?.expirationDate ?? null); + readonly creatorIdentifier = computed( + () => this.send()?.creatorIdentifier ?? null, + ); + readonly hideEmail = computed( + () => this.send() != null && this.creatorIdentifier() == null, + ); + readonly loading = signal(false); + readonly unavailable = signal(false); + readonly error = signal(false); + sendType = SendType; - loading = true; - unavailable = false; - error = false; - hideEmail = false; decKey!: SymmetricCryptoKey; constructor( @@ -53,50 +65,48 @@ export class SendViewComponent implements OnInit { private toastService: ToastService, private i18nService: I18nService, private layoutWrapperDataService: AnonLayoutWrapperDataService, - private cdRef: ChangeDetectorRef, + private configService: ConfigService, ) {} - 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(); + ngOnInit() { + void this.load(); } private async load() { - this.unavailable = false; - this.error = false; - this.hideEmail = false; - this.loading = true; - - let response = this.sendResponse(); + this.loading.set(true); + this.unavailable.set(false); + this.error.set(false); try { - if (!response) { - response = await this.sendApiService.postSendAccess(this.id(), this.accessRequest()); + const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + let response: SendAccessResponse; + if (sendEmailOtp) { + const accessToken = this.accessToken(); + if (!accessToken) { + this.authRequired.emit(); + return; + } + response = await this.sendApiService.postSendAccessV2(accessToken); + } else { + const sendResponse = this.sendResponse(); + if (!sendResponse) { + this.authRequired.emit(); + return; + } + response = sendResponse; } - 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); + const decSend = await sendAccess.decrypt(this.decKey); + this.send.set(decSend); } catch (e) { + this.send.set(null); if (e instanceof ErrorResponse) { if (e.statusCode === 401) { this.authRequired.emit(); } else if (e.statusCode === 404) { - this.unavailable = true; + this.unavailable.set(true); } else if (e.statusCode === 400) { this.toastService.showToast({ variant: "error", @@ -104,28 +114,23 @@ export class SendViewComponent implements OnInit { message: e.message, }); } else { - this.error = true; + this.error.set(true); } } else { - this.error = true; + this.error.set(true); } + } finally { + this.loading.set(false); } - 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) { + const creatorIdentifier = this.creatorIdentifier(); + if (creatorIdentifier != null) { this.layoutWrapperDataService.setAnonLayoutWrapperData({ pageSubtitle: { key: "sendAccessCreatorIdentifier", - placeholders: [this.creatorIdentifier], + placeholders: [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 a40cb3d4330..d65a8e997fd 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -23,7 +23,7 @@
diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html index a484f210f62..ce5b0e36728 100644 --- a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html @@ -1,6 +1,6 @@ - {{ dialogTitle() | i18n }} + {{ dialogTitle | i18n }}
- +
@@ -17,7 +17,13 @@

- {{ "sendCreatedDescriptionV2" | i18n: formattedExpirationTime }} + @let translationKey = + send.authType === AuthType.Email + ? "sendCreatedDescriptionEmail" + : send.authType === AuthType.Password + ? "sendCreatedDescriptionPassword" + : "sendCreatedDescriptionV2"; + {{ translationKey | i18n: formattedExpirationTime }}

diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.spec.ts b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.spec.ts new file mode 100644 index 00000000000..bfc35f208ed --- /dev/null +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.spec.ts @@ -0,0 +1,162 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +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 { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { + DIALOG_DATA, + DialogModule, + I18nMockService, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { SendSuccessDrawerDialogComponent } from "./send-success-drawer-dialog.component"; + +describe("SendSuccessDrawerDialogComponent", () => { + let fixture: ComponentFixture; + let component: SendSuccessDrawerDialogComponent; + let environmentService: MockProxy; + let platformUtilsService: MockProxy; + let toastService: MockProxy; + + let sendView: SendView; + + // Translation Keys + const newTextSend = "New Text Send"; + const newFileSend = "New File Send"; + const oneHour = "1 hour"; + const oneDay = "1 day"; + const sendCreatedSuccessfully = "Send has been created successfully"; + const sendCreatedDescriptionV2 = "Send ready to share with anyone"; + const sendCreatedDescriptionEmail = "Email-verified Send ready to share"; + const sendCreatedDescriptionPassword = "Password-protected Send ready to share"; + + beforeEach(async () => { + environmentService = mock(); + platformUtilsService = mock(); + toastService = mock(); + + sendView = { + id: "test-send-id", + authType: AuthType.None, + deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + type: SendType.Text, + accessId: "abc", + urlB64Key: "123", + } as SendView; + + Object.defineProperty(environmentService, "environment$", { + configurable: true, + get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })), + }); + + await TestBed.configureTestingModule({ + imports: [SharedModule, DialogModule, TypographyModule], + providers: [ + { + provide: DIALOG_DATA, + useValue: sendView, + }, + { provide: EnvironmentService, useValue: environmentService }, + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + newTextSend, + newFileSend, + sendCreatedSuccessfully, + sendCreatedDescriptionEmail, + sendCreatedDescriptionPassword, + sendCreatedDescriptionV2, + sendLink: "Send link", + copyLink: "Copy Send Link", + close: "Close", + oneHour, + durationTimeHours: (hours) => `${hours} hours`, + oneDay, + days: (days) => `${days} days`, + loading: "loading", + }); + }, + }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + { provide: ToastService, useValue: toastService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SendSuccessDrawerDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should have the correct title for text Sends", () => { + sendView.type = SendType.Text; + fixture.detectChanges(); + expect(component.dialogTitle).toBe("newTextSend"); + }); + + it("should have the correct title for file Sends", () => { + fixture.componentInstance.send.type = SendType.File; + fixture.detectChanges(); + expect(component.dialogTitle).toBe("newFileSend"); + }); + + it("should show the correct message for Sends with an expiration time of one hour from now", () => { + sendView.deletionDate = new Date(Date.now() + 1 * 60 * 60 * 1000); + fixture.detectChanges(); + expect(component.formattedExpirationTime).toBe(oneHour); + }); + + it("should show the correct message for Sends with an expiration time more than an hour but less than a day from now", () => { + const numHours = 8; + sendView.deletionDate = new Date(Date.now() + numHours * 60 * 60 * 1000); + fixture.detectChanges(); + expect(component.formattedExpirationTime).toBe(`${numHours} hours`); + }); + + it("should have the correct title for Sends with an expiration time of one day from now", () => { + sendView.deletionDate = new Date(Date.now() + 24 * 60 * 60 * 1000); + fixture.detectChanges(); + expect(component.formattedExpirationTime).toBe(oneDay); + }); + + it("should have the correct title for Sends with an expiration time of multiple days from now", () => { + const numDays = 3; + sendView.deletionDate = new Date(Date.now() + numDays * 24 * 60 * 60 * 1000); + fixture.detectChanges(); + expect(component.formattedExpirationTime).toBe(`${numDays} days`); + }); + + it("should show the correct message for successfully-created Sends with no authentication", () => { + sendView.authType = AuthType.None; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully); + expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionV2); + }); + + it("should show the correct message for successfully-created Sends with password authentication", () => { + sendView.authType = AuthType.Password; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully); + expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionPassword); + }); + + it("should show the correct message for successfully-created Sends with email authentication", () => { + sendView.authType = AuthType.Email; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully); + expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionEmail); + }); +}); 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 index 67e01cd9ff0..9d812bc77ba 100644 --- 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 @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, Inject, signal, computed } from "@angular/core"; +import { Component, ChangeDetectionStrategy, Inject, signal } from "@angular/core"; import { firstValueFrom } from "rxjs"; import { ActiveSendIcon } from "@bitwarden/assets/svg"; @@ -6,6 +6,7 @@ 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 { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DIALOG_DATA, DialogModule, ToastService, TypographyModule } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; @@ -16,13 +17,13 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendSuccessDrawerDialogComponent { + readonly AuthType = AuthType; readonly sendLink = signal(""); activeSendIcon = ActiveSendIcon; - // Computed property to get the dialog title based on send type - readonly dialogTitle = computed(() => { + get dialogTitle(): string { return this.send.type === SendType.Text ? "newTextSend" : "newFileSend"; - }); + } constructor( @Inject(DIALOG_DATA) public send: SendView, diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts index 54d62b8414a..51603724c57 100644 --- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts @@ -12,7 +12,7 @@ import { ActivatedRoute } from "@angular/router"; import { map, Observable, of, tap } from "rxjs"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; -import { ButtonComponent, IconModule } from "@bitwarden/components"; +import { ButtonComponent, SvgModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { @@ -24,7 +24,7 @@ import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manua @Component({ selector: "vault-browser-extension-prompt", templateUrl: "./browser-extension-prompt.component.html", - imports: [CommonModule, I18nPipe, ButtonComponent, IconModule, ManuallyOpenExtensionComponent], + imports: [CommonModule, I18nPipe, ButtonComponent, SvgModule, ManuallyOpenExtensionComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class BrowserExtensionPromptComponent implements OnInit, OnDestroy { diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html index d15cdaa712b..7da964f5fdb 100644 --- a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html +++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html @@ -1,8 +1,8 @@

{{ "openExtensionFromToolbarPart1" | i18n }} - + > {{ "openExtensionFromToolbarPart2" | i18n }}

diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts index 435e847f6e9..e4db0a55097 100644 --- a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts +++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts @@ -1,14 +1,14 @@ import { Component, ChangeDetectionStrategy } from "@angular/core"; import { BitwardenIcon } from "@bitwarden/assets/svg"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: "vault-manually-open-extension", templateUrl: "./manually-open-extension.component.html", - imports: [I18nPipe, IconModule], + imports: [I18nPipe, SvgModule], }) export class ManuallyOpenExtensionComponent { protected BitwardenIcon = BitwardenIcon; 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 8cfd394b854..d8cd562ac61 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 @@ -31,7 +31,7 @@
- +

{{ diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts index cfc1961c4d8..1b2c0144549 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts @@ -18,7 +18,7 @@ import { CenterPositionStrategy, DialogRef, DialogService, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -52,7 +52,7 @@ type SetupExtensionState = UnionOfValues; JslibModule, ButtonComponent, LinkModule, - IconModule, + SvgModule, RouterModule, AddExtensionVideosComponent, ManuallyOpenExtensionComponent, 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 059347709f0..ec06c740f24 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -3,7 +3,7 @@ {{ title }} - @if (isCipherArchived) { + @if (isCipherArchived && !params.isAdminConsoleAction) { {{ "archived" | i18n }} } diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts index 63b5071d1f5..276c0c2e6a3 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts @@ -104,7 +104,7 @@ describe("VaultItemDialogComponent", () => { getFeatureFlag$: () => of(false), }, }, - { provide: Router, useValue: {} }, + { provide: Router, useValue: { navigate: jest.fn() } }, { provide: ActivatedRoute, useValue: {} }, { provide: BillingAccountProfileStateService, @@ -303,6 +303,25 @@ describe("VaultItemDialogComponent", () => { }); }); + describe("archive badge", () => { + it('should show "archived" badge when the item is archived and not an admin console action', () => { + component.setTestCipher({ isArchived: true }); + component.setTestParams({ mode: "view" }); + fixture.detectChanges(); + const archivedBadge = fixture.debugElement.query(By.css("span[bitBadge]")); + expect(archivedBadge).toBeTruthy(); + expect(archivedBadge.nativeElement.textContent.trim()).toBe("archived"); + }); + + it('should not show "archived" badge when the item is archived and is an admin console action', () => { + component.setTestCipher({ isArchived: true }); + component.setTestParams({ mode: "view", isAdminConsoleAction: true }); + fixture.detectChanges(); + const archivedBadge = fixture.debugElement.query(By.css("span[bitBadge]")); + expect(archivedBadge).toBeFalsy(); + }); + }); + describe("submitButtonText$", () => { it("should return 'unArchiveAndSave' when premium is false and cipher is archived", (done) => { jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false)); @@ -337,4 +356,76 @@ describe("VaultItemDialogComponent", () => { }); }); }); + + describe("changeMode", () => { + beforeEach(() => { + component.setTestCipher({ type: CipherType.Login, id: "cipher-id" }); + }); + + it("refocuses the dialog header", async () => { + const focusOnHeaderSpy = jest.spyOn(component["dialogComponent"](), "focusOnHeader"); + + await component["changeMode"]("view"); + + expect(focusOnHeaderSpy).toHaveBeenCalled(); + }); + + describe("to view", () => { + beforeEach(() => { + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + }); + + it("sets mode to view", async () => { + await component["changeMode"]("view"); + + expect(component["params"].mode).toBe("view"); + }); + + it("updates the url", async () => { + const router = TestBed.inject(Router); + + await component["changeMode"]("view"); + + expect(router.navigate).toHaveBeenCalledWith([], { + queryParams: { action: "view", itemId: "cipher-id" }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + }); + }); + + describe("to form", () => { + const waitForFormReady = async () => { + const changeModePromise = component["changeMode"]("form"); + + expect(component["loadForm"]).toBe(true); + + component["onFormReady"](); + await changeModePromise; + }; + + beforeEach(() => { + component.setTestParams({ mode: "view" }); + fixture.detectChanges(); + }); + + it("waits for form to be ready when switching to form mode", async () => { + await waitForFormReady(); + + expect(component["params"].mode).toBe("form"); + }); + + it("updates the url", async () => { + const router = TestBed.inject(Router); + await waitForFormReady(); + + expect(router.navigate).toHaveBeenCalledWith([], { + queryParams: { action: "edit", itemId: "cipher-id" }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + }); + }); + }); }); diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 5d5e319c8af..ef861b7cab3 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -8,7 +8,7 @@ import { Inject, OnDestroy, OnInit, - ViewChild, + viewChild, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; @@ -50,6 +50,7 @@ import { ItemModule, ToastService, CenterPositionStrategy, + DialogComponent, } from "@bitwarden/components"; import { AttachmentDialogCloseResult, @@ -163,14 +164,11 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { * Reference to the dialog content element. Used to scroll to the top of the dialog when switching modes. * @protected */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild("dialogContent") - protected dialogContent: ElementRef; + protected readonly dialogContent = viewChild.required>("dialogContent"); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild(CipherFormComponent) cipherFormComponent!: CipherFormComponent; + private readonly cipherFormComponent = viewChild.required(CipherFormComponent); + + private readonly dialogComponent = viewChild(DialogComponent); /** * Tracks if the cipher was ever modified while the dialog was open. Used to ensure the dialog emits the correct result @@ -536,7 +534,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { updatedCipherView = await this.cipherService.decrypt(updatedCipher, activeUserId); } - this.cipherFormComponent.patchCipher((currentCipher) => { + this.cipherFormComponent().patchCipher((currentCipher) => { currentCipher.attachments = updatedCipherView.attachments; currentCipher.revisionDate = updatedCipherView.revisionDate; @@ -574,7 +572,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return; } - this.cipherFormComponent.patchCipher((current) => { + this.cipherFormComponent().patchCipher((current) => { current.revisionDate = revisionDate; current.archivedDate = archivedDate; return current; @@ -595,7 +593,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("itemsWereSentToArchive"), + message: this.i18nService.t("itemWasSentToArchive"), }); } catch { this.toastService.showToast({ @@ -691,7 +689,10 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { this.params.mode = mode; this.updateTitle(); // Scroll to the top of the dialog content when switching modes. - this.dialogContent.nativeElement.parentElement.scrollTop = 0; + this.dialogContent().nativeElement.parentElement.scrollTop = 0; + + // Refocus on title element, the built-in focus management of the dialog only works for the initial open. + this.dialogComponent().focusOnHeader(); // Update the URL query params to reflect the new mode. await this.router.navigate([], { diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 081829a8d83..3e62ccfd21d 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -26,7 +26,7 @@ title="{{ 'editItemWithName' | i18n: cipher.name }}" type="button" appStopProp - aria-haspopup="true" + aria-haspopup="dialog" > {{ cipher.name }} diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index f795f9533eb..6400c0ca9a8 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -157,7 +157,7 @@ export class VaultCipherRowComponent implements OnInit // If item is archived always show unarchive button, even if user is not premium protected get showUnArchiveButton() { - if (!this.archiveEnabled()) { + if (!this.archiveEnabled() || this.viewingOrgVault) { return false; } diff --git a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts index 46f2b5da735..9fcb6f0cec1 100644 --- a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts +++ b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts @@ -12,7 +12,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { CenterPositionStrategy, @@ -148,11 +147,16 @@ export class BulkDeleteDialogComponent { } private async deleteCiphersAdmin(ciphers: string[]): Promise { - const deleteRequest = new CipherBulkDeleteRequest(ciphers, this.organization.id); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); if (this.permanent) { - return await this.apiService.deleteManyCiphersAdmin(deleteRequest); + await this.cipherService.deleteManyWithServer(ciphers, userId, true, this.organization.id); } else { - return await this.apiService.putDeleteManyCiphersAdmin(deleteRequest); + await this.cipherService.softDeleteManyWithServer( + ciphers, + userId, + true, + this.organization.id, + ); } } diff --git a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html index 4fd9539f049..5c1dc5c7f3a 100644 --- a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html +++ b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html @@ -4,7 +4,7 @@ [disabled]="disabled" [style.color]="textColor" [style.background-color]="color" - appA11yTitle="{{ organizationName }}" + appA11yTitle="{{ 'ownerBadgeA11yDescription' | i18n: name }}" routerLink [queryParams]="{ organizationId: organizationIdLink }" queryParamsHandling="merge" diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 5ca3a11d5ab..fe4b7f1f96f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -33,7 +33,7 @@ import { EmptyTrash, FavoritesIcon, ItemTypes, - Icon, + BitSvg, } from "@bitwarden/assets/svg"; import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -160,7 +160,7 @@ type EmptyStateType = "trash" | "favorites" | "archive"; type EmptyStateItem = { title: string; description: string; - icon: Icon; + icon: BitSvg; }; type EmptyStateMap = Record; @@ -925,6 +925,7 @@ export class VaultComponent implements OnInit, OnDestr const dialogRef = AttachmentsV2Component.open(this.dialogService, { cipherId: cipher.id as CipherId, organizationId: cipher.organizationId as OrganizationId, + canEditCipher: cipher.edit, }); const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed); @@ -1536,8 +1537,7 @@ export class VaultComponent implements OnInit, OnDestr const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const cipherFullView = await this.cipherService.getFullCipherView(cipher); cipherFullView.favorite = !cipherFullView.favorite; - const encryptedCipher = await this.cipherService.encrypt(cipherFullView, activeUserId); - await this.cipherService.updateWithServer(encryptedCipher); + await this.cipherService.updateWithServer(cipherFullView, activeUserId); this.toastService.showToast({ variant: "success", diff --git a/apps/web/src/connectors/platform/proxy-cookie-redirect.html b/apps/web/src/connectors/platform/proxy-cookie-redirect.html new file mode 100644 index 00000000000..1daa6d2e412 --- /dev/null +++ b/apps/web/src/connectors/platform/proxy-cookie-redirect.html @@ -0,0 +1,29 @@ + + + + + + + + Bitwarden Web vault + + + + + + + + + +
+ Bitwarden +
+ +
+
+ + diff --git a/apps/web/src/connectors/platform/proxy-cookie-redirect.ts b/apps/web/src/connectors/platform/proxy-cookie-redirect.ts new file mode 100644 index 00000000000..79c5092caab --- /dev/null +++ b/apps/web/src/connectors/platform/proxy-cookie-redirect.ts @@ -0,0 +1,17 @@ +/** + * ONLY FOR SELF-HOSTED SETUPS + * Redirects the user to the SSO cookie vendor endpoint when the window finishes loading. + * + * This script listens for the window's load event and automatically redirects the browser + * to the `api/sso-cookie-vendor` path on the current origin. This is used as part + * of an authentication flow where cookies need to be set or validated through a vendor endpoint. + */ +window.addEventListener("DOMContentLoaded", () => { + const origin = window.location.origin; + let apiURL = `${window.location.origin}/api/sso-cookie-vendor`; + // Override for local testing + if (origin.startsWith("https://localhost")) { + apiURL = "http://localhost:4000/sso-cookie-vendor"; + } + window.location.href = apiURL; +}); diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index b717abbda7a..004742342c5 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "E-pos" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nee" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Een of meer organisasiebeleide verhoed u om u persoonlike kluis uit te stuur." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Kies SSO-tipe" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Kies ’n plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index 2f0ab382c7b..a14d85c1bdf 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "لا توجد تطبيقات حرجة في خطر" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "الوصول إلى الذكاء" }, @@ -250,6 +268,9 @@ "application": { "message": "تطبيق" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "كلمات المرور المعرضة للخطر" }, @@ -586,6 +607,9 @@ "email": { "message": "البريد الإلكتروني" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "الهاتف" }, @@ -1365,6 +1389,12 @@ "no": { "message": "لا" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "الموقع" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index 2c0e30015e6..ed133481a99 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -1,33 +1,51 @@ { "allApplications": { - "message": "Bütün tətbiqlər" + "message": "Bütün proqramlar" }, "activity": { "message": "Fəaliyyət" }, "appLogoLabel": { - "message": "Bitwarden loqosu" + "message": "Açar alətləri" }, "criticalApplications": { - "message": "Kritik tətbiqlər" + "message": "Kritik proqramlar" }, "noCriticalAppsAtRisk": { "message": "Risk altında heç bir kritik tətbiq yoxdur" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { - "message": "Access Intelligence" + "message": "Giriş məlumatları" }, "passwordRisk": { - "message": "Parol riski" + "message": "Açar təhlükəsi" }, "noEditPermissions": { - "message": "Bu elementə düzəliş etmə icazəniz yoxdur" + "message": "Bu bəndə düzəliş etmə icazəniz yoxdur" }, "reviewAtRiskPasswords": { - "message": "Tətbiqlər arasında riskli (zəif, ifşa olunmuş və ya təkrar istifadə olunmuş) parolları incələyin. İstifadəçilərinizin riskli parollara yönəlmiş təhlükəsizlik tədbirlərinə əhəmiyyət vermələri üçün kritik tətbiqlərinizi seçin." + "message": "Proqramlar arasında risk altında olan açarları (zəif, açıq və ya təkrar istifadə olunmuş) nəzərdən keçirin. İstehlakçılarınızın risk altında olan açarları həll etməsinə yönəlmiş təhlükəsizlik tədbirlərinə üstünlük vermək üçün ən vacib proqramlarınızı seçin." }, "reviewAtRiskLoginsPrompt": { - "message": "Riskli girişləri incələ" + "message": "Risk altında olan girişi nəzərdən keçirin" }, "dataLastUpdated": { "message": "Verilərin son güncəlləmə tarixi: $DATE$", @@ -39,10 +57,10 @@ } }, "noReportRan": { - "message": "Hələ heç bir hesabat yaratmamısınız" + "message": "Hələ heç bir hesabat qurmamısınız" }, "notifiedMembers": { - "message": "Məlumatlandırılan üzvlər" + "message": "Bildirilən üzvlər" }, "revokeMembers": { "message": "Üzvləri ləğv et" @@ -51,10 +69,10 @@ "message": "Üzvləri bərpa et" }, "cannotRestoreAccessError": { - "message": "Təşkilat erişimi bərpa edilə bilmir" + "message": "Təşkilata giriş bərpa edilə bilmir" }, "allApplicationsWithCount": { - "message": "Bütün tətbiqlər ($COUNT$)", + "message": "Bütün proqramlar ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -63,7 +81,7 @@ } }, "createNewLoginItem": { - "message": "Yeni giriş elementi yarat" + "message": "Yeni giriş elementi meydana gətirin" }, "percentageCompleted": { "message": "$PERCENT$% tamamlandı", @@ -88,28 +106,28 @@ } }, "passwordChangeProgress": { - "message": "Parol dəyişmə irəliləyişi" + "message": "Açar dəyişikliyi prosesi" }, "assignMembersTasksToMonitorProgress": { - "message": "İrəliləyişi izləmək üçün üzvlərə tapşırıqlar təyin edin" + "message": "Prosesi izləmək üçün üzvlərə tapşırıqlar təyin edin" }, "onceYouReviewApplications": { - "message": "Tətbiqləri incələyib kritik olaraq işarələdikdən sonra, üzvlərə parollarını dəyişməsi üçün tapşırıqlar təyin edin." + "message": "Proqramları nəzərdən keçirib kritik olaraq qeyd etdikdən sonra, üzvlərə açarları dəyişməsi üçün tapşırıqlar təyin edin." }, "sendReminders": { - "message": "Xatırlatma göndər" + "message": "Yaddaqalan qeydləri göndər" }, "onceYouMarkApplicationsCriticalTheyWillDisplayHere": { - "message": "Tətbiqləri kritik olaraq işarələsəniz, onlar burada nümayiş olunacaq." + "message": "Proqramları kritik olaraq qeyd etsəniz, onlar burada nümayiş olunacaq." }, "viewAtRiskMembers": { - "message": "Riskli üzvlərə bax" + "message": "Təhlükəli üzvlərə bax" }, "viewAtRiskApplications": { - "message": "Riskli tətbiqlərə bax" + "message": "Təhlükəli proqramlara bax" }, "criticalApplicationsAreAtRisk": { - "message": "$COUNT$/$TOTAL$ kritik tətbiq, riskli parollara görə risk altındadır", + "message": "$COUNT$/$TOTAL$ kritik tətbiq, təhlükəli şifrələrə görə risk altındadır", "placeholders": { "count": { "content": "$1", @@ -122,7 +140,7 @@ } }, "criticalApplicationsWithCount": { - "message": "Kritik tətbiqlər ($COUNT$)", + "message": "Kritik proqramlar ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -131,7 +149,7 @@ } }, "criticalApplicationsMarked": { - "message": "kritik olaraq işarələnmiş tətbiqlər" + "message": "kritik olaraq qeyd edilmiş proqramlar" }, "countOfCriticalApplications": { "message": "$COUNT$ kritik tətbiq", @@ -152,7 +170,7 @@ } }, "countOfAtRiskPasswords": { - "message": "$COUNT$ parol risk altındadır", + "message": "$COUNT$ Açar risk altındadır", "placeholders": { "count": { "content": "$1", @@ -161,7 +179,7 @@ } }, "newPasswordsAtRisk": { - "message": "$COUNT$ yeni parol risklidir", + "message": "$COUNT$ yeni açar təhlükə altındadır", "placeholders": { "count": { "content": "$1", @@ -170,7 +188,7 @@ } }, "notifiedMembersWithCount": { - "message": "Məlumatlandırılan üzvlər ($COUNT$)", + "message": "Bildirilən üzvlər ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -179,19 +197,19 @@ } }, "noDataInOrgTitle": { - "message": "Heç bir veri tapılmadı" + "message": "Heç bir məlumat aşkar olunmadı" }, "noDataInOrgDescription": { - "message": "Access Intelligence-i istifadə etməyə başlamaq üçün təşkilatınızın giriş verilərini daxilə köçürün. Bunu etdikdən sonra, bunları edə biləcəksiniz:" + "message": "Giriş məlumatı ilə başlamaq üçün müəssisənizin giriş məlumatlarını idxal edin. Bunu etdikdən sonra, bunları edə biləcəksiniz:" }, "feature1Title": { - "message": "Tətbiqləri kritik olaraq işarələmə" + "message": "Proqramları kritik kimi qeyd edin" }, "feature1Description": { - "message": "Bu, əvvəlcə ən vacib tətbiqlərinizdəki riskləri xaric etməyinizə kömək edəcək." + "message": "Bu, əvvəlcə ən vacib proqramlarınızdakı riskləri aradan qaldırmağa kömək edəcək." }, "feature2Title": { - "message": "Üzvlərin təhlükəsizliyini təkmilləşdirməsinə kömək" + "message": "Üzvlərə təhlükəsizliklərini yaxşılaşdırmağa kömək edin" }, "feature2Description": { "message": "Riskli üzvlərə kimlik məlumatlarını güncəlləməsi üçün təhlükəsizlik tapşırıqları təyin edin." @@ -221,7 +239,7 @@ "message": "Tətbiqi kritik olaraq işarələ" }, "markAsCritical": { - "message": "Kritik olaraq işarələ" + "message": "Kritik olaraq qeyd et" }, "applicationsSelected": { "message": "tətbiq seçildi" @@ -250,6 +268,9 @@ "application": { "message": "Tətbiq" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Riskli parollar" }, @@ -586,6 +607,9 @@ "email": { "message": "E-poçt" }, + "emails": { + "message": "E-poçtlar" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Xeyr" }, + "noAuth": { + "message": "Keçidə sahib olan hər kəs" + }, + "anyOneWithPassword": { + "message": "Sizin təyin etdiyiniz parola sahib hər kəs" + }, "location": { "message": "Yerləşmə" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Növbəti ödəniş" }, + "nextChargeDate": { + "message": "Növbəti ödəniş vaxtı" + }, "plan": { "message": "Plan" }, @@ -5419,10 +5452,10 @@ "message": "Seçiləni bərpa et" }, "archivedItemRestored": { - "message": "Archived item restored" + "message": "Arxivlənmiş element bərpa edildi" }, "archivedItemsRestored": { - "message": "Archived items restored" + "message": "Arxivlənmiş elementlər bərpa edildi" }, "restoredItem": { "message": "Element bərpa edildi" @@ -5626,13 +5659,13 @@ "message": "Send uğurla yaradıldı!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Bu Send keçidini kopyala və paylaş. Qeyd etdiyiniz şəxslər buna növbəti $TIME$ ərzində baxa bilər.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5916,35 +5949,35 @@ } }, "centralizeDataOwnership": { - "message": "Centralize organization ownership" + "message": "Təşkilat sahibliyini mərkəzləşdirin" }, "centralizeDataOwnershipDesc": { - "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + "message": "Bütün üzv elementləri, təşkilat tərəfindən sahiblənəcək və idarə ediləcək. Adminlər və sahibləri daxil deyil. " }, "centralizeDataOwnershipContentAnchor": { - "message": "Learn more about centralized ownership", + "message": "Mərkəzi sahiblik barədə daha ətraflı", "description": "This will be used as a hyperlink" }, "benefits": { - "message": "Benefits" + "message": "Faydaları" }, "centralizeDataOwnershipBenefit1": { - "message": "Gain full visibility into credential health, including shared and unshared items." + "message": "Paylaşılan və paylaşılmayan elementlər daxil olmaqla kimlik məlumatı sağlamlığına tam görünmə əldə edin." }, "centralizeDataOwnershipBenefit2": { - "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + "message": "Üzvlərin tərk etməsi və təhvil zamanı elementləri asanlıqla köçürün, erişim zamanı heç bir boşluğa yer buraxmayın." }, "centralizeDataOwnershipBenefit3": { - "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + "message": "Bütün istifadəçilərə öz giriş məlumatlarını idarə edə biləcəkləri \"Elementlərim\" sahəsi verin." }, "centralizeDataOwnershipWarningTitle": { - "message": "Prompt members to transfer their items" + "message": "Üzvlərdən öz elementlərini köçürməsi soruşulsun" }, "centralizeDataOwnershipWarningDesc": { - "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + "message": "Üzvlərin fərdi seyflərində elementləri varsa, onlardan elementləri təşkilata köçürməsi, ya da tərk etməsi soruşulacaq. Əgər tərk etsələr, erişimləri ləğv ediləcək, ancaq istənilən vaxt bərpa edə biləcəklər." }, "centralizeDataOwnershipWarningLink": { - "message": "Learn more about the transfer" + "message": "Köçürmə barədə daha ətraflı" }, "organizationDataOwnership": { "message": "Təşkilata veri üzərində məcburi sahiblik ver" @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Bir və ya daha çox təşkilat siyasəti, fərdi seyfi xaricə köçürməyinizi əngəlləyir." }, - "activateAutofill": { - "message": "Avto-doldurmanı aktivləşdir" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Bütün mövcud və yeni üzvlər üçün brauzer uzantısında səhifə yüklənəndə avto-doldurmanı ayarını aktivləşdirin." + "activateAutofillPolicyDescription": { + "message": "Bütün mövcud və yeni üzvlər üçün brauzer uzantısında \"Səhifə yüklənəndə avto-doldur\" ayarını aktivləşdirin." }, - "experimentalFeature": { - "message": "Təhlükəli və ya güvənilməyən veb saytlar, səhifə yüklənərkən avto-doldurmanı istifadə edə bilər." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Avto-doldurma haqqında daha ətraflı" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "SSO növü seçin" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Seyf event verilərini Datadog serverinizə göndərin" }, + "huntressEventIntegrationDesc": { + "message": "Event verilərini öz \"Huntress SIEM instance\"na göndər" + }, "failedToSaveIntegration": { "message": "İnteqrasiya saxlanılmadı. Lütfən daha sonra yenidən sınayın." }, @@ -10543,6 +10579,12 @@ "index": { "message": "İndeks" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Bir plan seçin" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden, ilk 72 saat ərzində domeni 3 dəfə götürməyə çalışacaq. Əgər domen götürülə bilməsə, \"host\"unuzdakı DNS qeydini yoxlayın və manual götürün. Domen götürülməsə, 7 gün ərzində təşkilatınızdan silinəcək." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden, domeni 72 saat ərzində götürməyə çalışacaq. Domen götürülə bilmirsə, DNS qeydinizi doğrulayın və manual götürün. Götürülməmiş domenlər 7 gün sonra xaric edilir." + }, + "automaticDomainClaimProcess2": { + "message": "Götürüldükdən sonra, domeni götürmüş mövcud üzvlərə e-poçtla məlumat veriləcək " + }, + "accountOwnershipChange": { + "message": "hesab sahibliyinin dəyişməsi" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ götürülmədi. DNS qeydlərinizi yoxlayın.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Götürüldü" }, - "domainStatusUnderVerification": { - "message": "Doğrulama altında" + "domainStatusPending": { + "message": "Gözlənilir" }, "claimedDomainsDescription": { "message": "Üzv hesablarına sahiblik etmək üçün bir domen götürün. Domen götürmüş üzvlər üçün giriş zamanı SSO identifikatoru səhifəsi ötürüləcək və inzibatçılar bu domenə aid hesabları silə biləcək." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "İndi doğrula." }, + "unlockWithPasskey": { + "message": "Kilidi keçid açarı ilə aç" + }, + "prfUnlockFailed": { + "message": "Kilid keçid açarı ilə açılmadı. Lütfən yenidən sınayın, ya da başqa kilid açma üsulunu sınayın." + }, + "noPrfCredentialsAvailable": { + "message": "Kilidi açmaq üçün PRF dəstəkli keçid açarı yoxdur." + }, "additionalStorageGB": { "message": "Əlavə anbar sahəsi GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "Bütün $GB$ GB-lıq şifrələnmiş anbar sahənizi istifadə etmisiniz. Faylları saxlaya bilmək üçün daha çox anbar sahəsi əlavə edin." }, + "whoCanView": { + "message": "Kimlər baxa bilər" + }, + "specificPeople": { + "message": "Xüsusi insanlar" + }, + "emailVerificationDesc": { + "message": "Bu Send keçidini paylaşdıqdan sonra, bu \"Send\"ə baxması üçün insanlar e-poçtlarını bir kodla doğrulamalıdırlar." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Birdən çox e-poçtu daxil edərkən vergül istifadə edin." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Anbar sahəsini xaric etdiyiniz zaman, növbəti hesabınıza avtomatik olaraq köçürüləcək mütənasib hesab krediti alacaqsınız." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "E-poçt qorunur" + }, + "invalidSendPassword": { + "message": "Yararsız Send parolu" } } diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index 5167a966ac5..3f339dbbb2e 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Кіраванне доступам" }, @@ -250,6 +268,9 @@ "application": { "message": "Праграма" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Паролі ў зоне рызыкі" }, @@ -586,6 +607,9 @@ "email": { "message": "Электронная пошта" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Тэлефон" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Адна або больш палітык арганізацыі не дазваляюць вам экспартаваць асабістае сховішча." }, - "activateAutofill": { - "message": "Актываваць аўтазапаўненне" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Актываваць аўтазапаўненне падчас загрузкі старонкі ў наладах пашырэння браўзера для ўсіх існуючых і новых удзельнікаў." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Скампраметаваныя або ненадзейныя вэб-сайты могуць задзейнічаць функцыю аўтазапаўнення падчас загрузкі старонкі." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Даведацца больш пра аўтазапаўненне" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Выберыце тып SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index ae77e08a333..a180f31de62 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Няма важни приложения в риск" }, + "critical": { + "message": "Критични ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Некритични ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Анализ на достъпа" }, @@ -250,6 +268,9 @@ "application": { "message": "Приложение" }, + "applications": { + "message": "Приложения" + }, "atRiskPasswords": { "message": "Пароли в риск" }, @@ -586,6 +607,9 @@ "email": { "message": "Електронна поща" }, + "emails": { + "message": "Е-пощи" + }, "phone": { "message": "Телефон" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Всеки с връзката" + }, + "anyOneWithPassword": { + "message": "Всеки с парола, зададена от Вас" + }, "location": { "message": "Местоположение" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Следващо плащане" }, + "nextChargeDate": { + "message": "Следваща дата за таксуване" + }, "plan": { "message": "План" }, @@ -5626,13 +5659,13 @@ "message": "Изпращането е създадено успешно!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Копирайте и споделете връзка към това Изпращане. То ще може да бъде видяно само от хората, които сте посочили, в рамките на следващите $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Копирайте и споделете връзката към Изпращането. То ще бъде достъпно за всеки с връзката в рамките на следващите $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Една или повече от настройките на организацията Ви не позволяват да изнасяте личния си трезор." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Включване на автоматичното попълване" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Включване на автоматичното попълване при зареждане на страница в браузърното разширение на всички текущи и бъдещи членове." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Компроментирани и измамни уеб сайтове могат да се възползват от автоматичното попълване при зареждане на страницата." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Научете повече относно автоматичното попълване" }, "selectType": { @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Изпращане на данните за събитията в трезора към Вашата инсталация на Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Изпращане на данни за събитията до Вашата инстанция на Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Интеграцията не беше запазена. Опитайте отново по-късно." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Индекс" }, + "httpEventCollectorUrl": { + "message": "Адрес на събирача на събития по HTTP" + }, + "httpEventCollectorToken": { + "message": "Идентификатор на събирача на събития по HTTP" + }, "selectAPlan": { "message": "Изберете план" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Битуорден ще се опита да присвои домейна 3 пъти през първите 72 часа. Ако той не може да бъде присвоен, проверете записа за DNS в сървъра си и направете присвояването ръчно. Домейнът ще бъде премахнат от организацията Ви след 7 дни, ако не бъде присвоен." }, + "automaticDomainClaimProcess1": { + "message": "Битуорден ще се опита да присвои домейна в рамките на 72 часа. Ако той не може да бъде присвоен, проверете записа си в DNS и направете присвояването ръчно. Неприсвоените домейни се премахват след 7 дни." + }, + "automaticDomainClaimProcess2": { + "message": "След присвояването, текущите членове с присвоени домейни ще получат е-писмо относно " + }, + "accountOwnershipChange": { + "message": "промяната на собствеността на акаунта" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "Домейнът $DOMAIN$ не е присвоен. Проверете записите в DNS.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Присвоен" }, - "domainStatusUnderVerification": { - "message": "В процес на проверка" + "domainStatusPending": { + "message": "На изчакване" }, "claimedDomainsDescription": { "message": "Присвойте домейн, за да притежавате акаунтите на членовете. Страницата за еднократно удостоверяване ще бъде пропускана при вписването на членове с присвоени домейни, а администраторите ще могат да изтриват присвоените акаунти." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Потвърдете сега." }, + "unlockWithPasskey": { + "message": "Отключване със секретен ключ" + }, + "prfUnlockFailed": { + "message": "Отключването със секретен ключ не беше успешно. Опитайте отново или използвайте друг начин за отключване." + }, + "noPrfCredentialsAvailable": { + "message": "Няма секретни ключове с включено PRF, налични за отключване." + }, "additionalStorageGB": { "message": "Допълнително място в ГБ" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "Използвали сте всичките си $GB$ GB от наличното си място за съхранение на шифровани данни. Ако искате да продължите да добавяте файлове, добавете повече място за съхранение." }, + "whoCanView": { + "message": "Кой може да преглежда" + }, + "specificPeople": { + "message": "Определени хора" + }, + "emailVerificationDesc": { + "message": "След като споделите тази връзка към Изпращане, хората ще трябва да потвърдят е-пощата си чрез код, за да могат да видят това Изпращане." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Можете да въведете повече е-пощи, като ги разделите със запетая." + }, + "emailPlaceholder": { + "message": "потребител@bitwarden.com , потребител@acme.com" + }, "whenYouRemoveStorage": { "message": "Когато премахнете съхранението, ще получите пропорционално задължение към акаунта си, което ще бъде включено автоматично в следващата Ви сметка." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Е-пощата е защитена" + }, + "invalidSendPassword": { + "message": "Неправилна парола за Изпращане" } } diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index 803ea21169f..415284c3a5f 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "ই-মেইল" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ফোন" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index 130fef41c29..cd3e8db2d58 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Imejl" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index 642a67d93ea..f0b3dc380a5 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Intel·ligència d'accés" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplicació" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Correu electrònic" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telèfon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Una o més polítiques d'organització us impedeixen exportar la vostra caixa forta." }, - "activateAutofill": { - "message": "Activa l'emplenament automàtic" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activeu l'emplenament automàtic a la configuració de la càrrega de la pàgina en l'extensió del navegador per a tots els membres existents i nous." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Els llocs web compromesos o no fiables poden aprofitar l'emplenament automàtic en carregar de la pàgina." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Més informació sobre l'emplenament automàtic" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Selecciona el tipus d'SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Seleccioneu un pla" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 046bf3e2fea..6f862bb4c51 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Žádné ohrožené kritické aplikace" }, + "critical": { + "message": "Kritické ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Nekritické ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Přístup k inteligenci" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplikace" }, + "applications": { + "message": "Aplikace" + }, "atRiskPasswords": { "message": "Ohrožená hesla" }, @@ -586,6 +607,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-maily" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Kdokoli s odkazem" + }, + "anyOneWithPassword": { + "message": "Kdokoli s heslem od Vás" + }, "location": { "message": "Umístění" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Další platba" }, + "nextChargeDate": { + "message": "Datum další platby" + }, "plan": { "message": "Plán" }, @@ -5626,13 +5659,13 @@ "message": "Send byl úspěšně vytvořen!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Zkopírujte a sdílejte tento Send pro odesílání. Můžou jej zobrazit osoby, které jste zadali, a to po dobu $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Zkopírujte a sdílejte tento odkaz Send. Send bude k dispozici komukoli s odkazem na dalších $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Jedna nebo více zásad organizace Vám brání v exportu Vašeho osobního trezoru." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Aktivovat automatické vyplnění" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Aktivuje automatické vyplnění při načítání stránky pro rozšíření prohlížeče pro všechny existující i nové členy." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Kompromitované nebo nedůvěryhodné webové stránky mohou zneužívat automatické vyplňování při načítání stránky." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Více informací o automatickém vyplňování" }, "selectType": { @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Odeslat data o trezoru do Vaší instance Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Odešle data události do Vaší instanci SIEM Huntress" + }, "failedToSaveIntegration": { "message": "Nepodařilo se uložit integraci. Opakujte akci později." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "URL kolektoru HTTP událostí" + }, + "httpEventCollectorToken": { + "message": "Token kolektoru HTTP událostí" + }, "selectAPlan": { "message": "Vyberte plán" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden se pokusí uplatnit doménu třikrát během prvních 72 hodin. Pokud doménu nelze uplatnit, zkontrolujte záznam DNS v hostitelském počítači a uplatněte ji ručně. Pokud se doménu nepodaří uplatnit, bude z Vaší organizace odebrána do 7 dnů." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden se pokusí nárokovat doménu do 72 hodin. Pokud doménu nelze nárokovat, ověřte svůj DNS záznam a nárokujte ručně. Nenárokované domény jsou odebrány po 7 dnech." + }, + "automaticDomainClaimProcess2": { + "message": "Po nárokování budou stávající členové s nárokovanými doménami obeznámeni e-mailem o " + }, + "accountOwnershipChange": { + "message": "změně vlastnictví účtu" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nebyla uplatněna. Zkontrolujte DNS záznamy.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Uplatněno" }, - "domainStatusUnderVerification": { - "message": "V ověřování" + "domainStatusPending": { + "message": "Čekající" }, "claimedDomainsDescription": { "message": "Požádejte o doménu, abyste mohli vlastnit členské účty. Stránka s identifikátorem SSO bude při přihlašování členů s deklarovanými doménami přeskočena a správci budou moci deklarované účty smazat." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Ověřit nyní" }, + "unlockWithPasskey": { + "message": "Odemknout pomocí přístupového klíče" + }, + "prfUnlockFailed": { + "message": "Nepodařilo se odemknout pomocí přístupového klíče. Zkuste to znovu nebo použijte jinou metodu odemknutí." + }, + "noPrfCredentialsAvailable": { + "message": "K odemknutí nejsou k dispozici žádné přístupové klíče s podporou PRF." + }, "additionalStorageGB": { "message": "Další úložiště (GB)" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "Využili jste celých $GB$ GB Vašeho šifrovaného úložiště. Chcete-li pokračovat v ukládání souborů, přidejte další úložiště." }, + "whoCanView": { + "message": "Kdo může zobrazit" + }, + "specificPeople": { + "message": "Vybraní lidé" + }, + "emailVerificationDesc": { + "message": "Po sdílení tohoto odkazu Send budou muset jednotlivci ověřit svůj e-mail pomocí kódu pro zobrazení tohoto Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Zadejte více e-mailů oddělených čárkou." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Když odeberete úložiště, obdržíte kredit, který bude automaticky převeden do Vašeho dalšího vyúčtování." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "E-mail je chráněný" + }, + "invalidSendPassword": { + "message": "Neplatné heslo k Send" } } diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index dc637d23b13..9160351f225 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Ebost" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Ffôn" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 36f716ea94b..9e0593c546e 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Adgangsefterretning" }, @@ -250,6 +268,9 @@ "application": { "message": "Applikation" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Udsatte adgangskoder" }, @@ -586,6 +607,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nej" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "En eller flere organisationspolitikker forhindrer eksport af din personlige boks." }, - "activateAutofill": { - "message": "Aktivér autoudfyldning" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktivér indstillingen Autoudfyldning ved sideindlæsning i browserudvidelsen for alle eksisterende og nye medlemmer." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Kompromitterede eller ikke-betroede websteder kan udnytte autoudfyldning ved sideindlæsning." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Læs mere om autoudfyldning" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Vælg SSO-type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Vælg en abonnementstype" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden vil forsøge at registrere domænet 3 gange i løbet af de første 72 timer. Kan domænet ikke registreres, tjek DNS-posten på værten og registrér manuelt. Såfremt uregistreret efter 7 dage, fjernes domænet fra organisationen." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ ikke registreret. Tjek DNS-posterne.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Registreret" }, - "domainStatusUnderVerification": { - "message": "Under verifikation" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 647ebc8946e..26536548d09 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Keine kritischen Anwendungen gefährdet" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Anwendung" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Gefährdete Passwörter" }, @@ -586,6 +607,9 @@ "email": { "message": "E-Mail" }, + "emails": { + "message": "E-Mails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nein" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Standort" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Nächste Abbuchung" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Tarif" }, @@ -5626,13 +5659,13 @@ "message": "Send erfolgreich erstellt!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Kopiere und teile diesen Send-Link. Er kann von den von dir angegebenen Personen für die nächsten $TIME$ angesehen werden.", + "sendCreatedDescriptionV2": { + "message": "Kopiere und teile diesen Send-Link. Das Send wird für jeden mit dem Link für die nächsten $TIME$ verfügbar sein.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Eine oder mehrere Unternehmensrichtlinien verhindern es, dass du deinen persönlichen Tresor exportieren kannst." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Auto-Ausfüllen aktivieren" }, - "activateAutofillPolicyDesc": { - "message": "Aktiviere die Einstellung \"Auto-Ausfüllen beim Laden einer Seite\" in der Browser-Erweiterung für alle bestehenden und neuen Mitglieder." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Kompromittierte oder nicht vertrauenswürdige Websites können Auto-Ausfüllen beim Laden der Seite ausnutzen." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Erfahre mehr über Auto-Ausfüllen" }, "selectType": { @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Tresor-Ereignisdaten an deine Datadog-Instanz senden" }, + "huntressEventIntegrationDesc": { + "message": "Sende Ereignisdaten an deine Huntress SIEM-Instanz" + }, "failedToSaveIntegration": { "message": "Fehler beim Speichern der Integration. Bitte versuche es später erneut." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Ereignissammler-URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Ereignissammler-Token" + }, "selectAPlan": { "message": "Einen Tarif auswählen" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden wird in den ersten 72 Stunden 3 Mal versuchen, die Domain zu beanspruchen. Wenn die Domain nicht beansprucht werden kann, überprüfe den DNS-Eintrag auf deinem Host und beanspruche sie manuell. Die Domain wird in 7 Tagen aus deiner Organisation entfernt, wenn sie nicht beansprucht ist." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nicht beansprucht. Überprüfe deine DNS-Einträge.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Beansprucht" }, - "domainStatusUnderVerification": { - "message": "In Verifizierung" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Beanspruche eine Domain für eigene Mitgliederkonten. Die SSO-Kennungsseite wird beim Anmelden für Mitglieder mit beanspruchten Domains übersprungen und Administratoren werden in der Lage sein, beanspruchte Konten zu löschen." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Jetzt verifizieren." }, + "unlockWithPasskey": { + "message": "Mit Passkey entsperren" + }, + "prfUnlockFailed": { + "message": "Entsperren mit Passkey fehlgeschlagen. Bitte versuche es erneut oder verwende eine andere Entsperrmethode." + }, + "noPrfCredentialsAvailable": { + "message": "Es sind keine PRF-fähigen Passkeys zum Entsperren verfügbar." + }, "additionalStorageGB": { "message": "Zusätzlicher Speicher GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "Du hast die gesamten $GB$ GB deines verschlüsselten Speichers verwendet. Um mit dem Speichern von Dateien fortzufahren, füge mehr Speicher hinzu." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Wenn du Speicherplatz entfernst, erhältst du eine anteilige Gutschrift, die automatisch mit deiner nächsten Rechnung verrechnet wird." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "E-Mail-Adresse geschützt" + }, + "invalidSendPassword": { + "message": "Ungültiges Send-Passwort" } } diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index d6338f4b1a6..32311ab1855 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Πληροφορίες Πρόσβασης" }, @@ -250,6 +268,9 @@ "application": { "message": "Εφαρμογή" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Κωδικοί πρόσβασης σε κίνδυνο" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Τηλέφωνο" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Όχι" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Τοποθεσία" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Μία ή περισσότερες οργανωτικές πολιτικές σας αποτρέπει από την εξαγωγή του προσωπικού vault." }, - "activateAutofill": { - "message": "Ενεργοποίηση αυτόματης συμπλήρωσης" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Μάθετε περισσότερα για την αυτόματη συμπλήρωση" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Επιλογή Τύπου SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Επιλογή προγράμματος" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 01e61612b40..cfcd9df5d4b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -14,6 +14,27 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical":{ + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, + "criticalBadge":{ + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +271,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +610,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1392,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3314,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3769,6 +3805,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, @@ -5636,6 +5675,26 @@ } } }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { + "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", + "placeholders": { + "time": { + "content": "$1", + "example": "7 days, 1 hour, 1 day" + } + } + }, "durationTimeHours": { "message": "$HOURS$ hours", "placeholders": { @@ -6925,17 +6984,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11425,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11449,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12101,6 +12172,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12667,15 +12747,45 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, + "ownerBadgeA11yDescription":{ + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders":{ + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, "youHavePremium": { "message": "You have Premium" }, "emailProtected": { "message": "Email protected" }, + "invalidSendPassword": { + "message": "Invalid Send password" + }, + "perUser": { + "message": "per user" + }, "upgradeToTeams": { "message": "Upgrade to Teams" }, diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index b6523df30b1..7e389243398 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organisation policies prevent you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organisation in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index 35a5d72c012..4e9aa6adf4c 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organisation policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organisation in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 59e907c6dc3..babe78377a3 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplikaĵo" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Retpoŝto" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefono" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Loko" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "En konfirmado" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 41406744b2d..3535a3d94fe 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No hay aplicaciones críticas en riesgo" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Inteligencia de Acceso" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplicación" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Correo electrónico" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Teléfono" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Ubicación" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Una o más políticas de tu organización te impiden exportar tu caja fuerte personal." }, - "activateAutofill": { - "message": "Activar autocompletar" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Active el autocompletar con los ajustes de carga de página en la extensión del navegador para todos los miembros existentes y nuevos." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Los sitios web comprometidos o no confiables pueden explotar autocompletar al cargar la página." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Más información sobre autocompletar" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Seleccionar tipo de SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Selecciona un plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index 3dd2852cb5a..f094c6858e7 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoninumber" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Asukoht" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Üks või enam organisatsiooni poliitikat ei võimalda sul oma personaalset hoidlat eksportida." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index 7fc629306a2..4ba28b78f39 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Emaila" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefonoa" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ez" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Erakundeko politika batek edo gehiagok kutxa gotorra esportatzea galarazten dute." }, - "activateAutofill": { - "message": "Gaitu betetze automatikoa" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Automatikoki betetzeari buruzko informazio gehiago" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Aukeratu SSO mota" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index dc46ab14304..c2ebbf86c40 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "هیچ برنامه حیاتی در معرض خطر نیست" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "دسترسی به هوش مصنوعی" }, @@ -250,6 +268,9 @@ "application": { "message": "برنامه" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "کلمات عبور در معرض خطر" }, @@ -586,6 +607,9 @@ "email": { "message": "ایمیل" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "تلفن" }, @@ -1365,6 +1389,12 @@ "no": { "message": "خیر" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "موقعیت" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "یک یا چند خط مشی سازمان از برون ریزی گاوصندوق شخصی شما جلوگیری می‌کند." }, - "activateAutofill": { - "message": "پر کردن خودکار را فعال کنید" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "تنظیم پر کردن خودکار در بارگذاری صفحه را در افزونه مرورگر برای همه اعضای موجود و جدید فعال کنید." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "وب‌سایت‌های در معرض خطر یا نامعتبر می‌توانند از پر کردن خودکار در بارگذاری صفحه سوء استفاده کنند." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "درباره پر کردن خودکار بیشتر بدانید" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "نوع SSO را انتخاب کنید" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "یک طرح انتخاب کنید" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden در ۷۲ ساعت اول سه بار تلاش خواهد کرد دامنه را ثبت کند. اگر دامنه ثبت نشد، رکورد DNS در میزبان خود را بررسی کرده و به‌صورت دستی ثبت کنید. اگر دامنه ثبت نشود، پس از ۷ روز از سازمان شما حذف خواهد شد." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "دامنه $DOMAIN$ ثبت نشده است. رکوردهای DNS خود را بررسی کنید.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "ثبت شده" }, - "domainStatusUnderVerification": { - "message": "در حال تأیید" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index fa347ae1910..80a7767a92f 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Sovellus" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Riskialttiit salasanat" }, @@ -586,6 +607,9 @@ "email": { "message": "Sähköpostiosoite" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Puhelinnumero" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sijainti" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Yksi tai useampi organisaatiokäytäntö estää yksityisen holvisi viennin." }, - "activateAutofill": { - "message": "Aktivoi automaattitäyttö" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktivoi selainlaajennuksen \"Automaattitäyttö sivun avautuessa\" -asetus kaikille nykyisille ja uusille jäsenille." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Vaarantuneet tai epäluotettavat sivustot voivat väärinkäyttää sivun avautuessa suoritettavaa automaattitäyttöä." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Lue lisää automaattitäytöstä" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Valitse kertakirjautumisen tyyppi" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Valitse tilaus" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Vahvistettavana" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index 1d18e7d3b38..81d9a416f50 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepono" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Hindi" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Ang isa o higit pang mga patakaran ng organisasyon ay nagpipigil sa iyo mula sa pag-export ng iyong indibidwal na vault." }, - "activateAutofill": { - "message": "Buksan ang autofill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Maaaring pagsamantalahan ng mga nakompromisong website ang pag-autofill pagka-load ng pahina." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Matuto pa tungkol sa autofill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Pumili ng uri ng SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index 3ed1616cf78..72d61e45eff 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Aucune application critique à risques" }, + "critical": { + "message": "Critique ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Non critique ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Accéder à Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Mots de passes à risque" }, @@ -586,6 +607,9 @@ "email": { "message": "Courriel" }, + "emails": { + "message": "Courriels" + }, "phone": { "message": "Téléphone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Toute personne disposant du lien" + }, + "anyOneWithPassword": { + "message": "N'importe qui avec un mot de passe défini par vous" + }, "location": { "message": "Localisation" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Prochain paiement" }, + "nextChargeDate": { + "message": "Prochaine date de facturation" + }, "plan": { "message": "Forfait" }, @@ -5626,13 +5659,13 @@ "message": "Send créé avec succès !", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copiez et partagez ce lien Send. Il peut être consulté par les personnes que vous avez spécifiées pour $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copiez et partagez ce lien Send. Le Send sera disponible à quiconque avec le lien pour les prochains $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Une ou plusieurs politiques de sécurité de l'organisation vous empêchent d'exporter votre coffre individuel." }, - "activateAutofill": { - "message": "Activer la saisie automatique" + "activateAutofillPolicy": { + "message": "Activer le remplissage automatique" }, - "activateAutofillPolicyDesc": { - "message": "Activer le paramètre de saisie automatique au chargement de la page sur l'extension du navigateur pour tous les membres existants et nouveaux." + "activateAutofillPolicyDescription": { + "message": "Activer le remplissage automatique au chargement de la page dans les paramètres de l'extension du navigateur pour tous les membres existants et nouveaux." }, - "experimentalFeature": { - "message": "Les sites web compromis ou non fiables peuvent exploiter la saisie automatique au chargement de la page." + "autofillOnPageLoadExploitWarning": { + "message": "Les sites web compromis ou non fiables peuvent exploiter le remplissage automatique au chargement de la page." }, - "learnMoreAboutAutofill": { - "message": "En savoir plus sur la saisie automatique" + "learnMoreAboutAutofillPolicy": { + "message": "En savoir plus sur le remplissage automatique" }, "selectType": { "message": "Sélectionnez le type de SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Envoyer les données de l'événement du coffre à votre instance Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Envoyer les données de l'événement à votre instance de Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Impossible d'enregistrer l'intégration. Veuillez réessayer plus tard." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "URL HTTP du Collecterur d'Événements" + }, + "httpEventCollectorToken": { + "message": "Jeton du Collecteur d'Événements HTTP" + }, "selectAPlan": { "message": "Sélectionnez un plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden tentera de récupérer le domaine 3 fois pendant les 72 premières heures. Si le domaine ne peut pas être réclamé, vérifiez l'enregistrement DNS dans votre hôte et réclamez manuellement. Le domaine sera supprimé de votre organisation dans 7 jours s'il n'est pas réclamé." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden tentera de réclamer le domaine dans les 72 heures. Si le domaine ne peut pas être réclamé, vérifiez votre enregistrement DNS et réclamez-le manuellement. Les domaines non réclamés sont supprimés après 7 jours." + }, + "automaticDomainClaimProcess2": { + "message": "Une fois réclamé, les membres existants ayant des domaines réclamés recevrontdes courriels à propos de la " + }, + "accountOwnershipChange": { + "message": "changement de propriétaire du compte" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ non réclamé. Vérifiez vos enregistrements DNS.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Réclamé" }, - "domainStatusUnderVerification": { - "message": "En cours de vérification" + "domainStatusPending": { + "message": "En attente" }, "claimedDomainsDescription": { "message": "Réclamer un domaine pour réclamer les comptes des membres. La page d'identification SSO sera ignorée lors de la connexion pour les membres ayant des domaines réclamés et les administrateurs pourront supprimer les comptes réclamés." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Vérifier maintenant." }, + "unlockWithPasskey": { + "message": "Déverrouiller avec une clé d'accès" + }, + "prfUnlockFailed": { + "message": "Le déverrouillage par clé d'accès a échoué. Veuillez réessayer ou utiliser une autre méthode pour déverrouiller." + }, + "noPrfCredentialsAvailable": { + "message": "Aucune clé d’accès avec PRF activé n’est disponible pour le déverrouillage." + }, "additionalStorageGB": { "message": "Stockage additionnel (Go)" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "Vous avez utilisé tous les $GB$ Go de votre stockage chiffré. Pour continuer à stocker des fichiers, ajoutez plus de stockage." }, + "whoCanView": { + "message": "Qui peut afficher" + }, + "specificPeople": { + "message": "Personnes spécifiques" + }, + "emailVerificationDesc": { + "message": "Après avoir partagé ce lien Send, les personnes devront vérifier leur courriel avec un code pour afficher ce Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Entrez plusieurs courriels en les séparant avec une virgule." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Lorsque vous supprimez le stockage, vous recevrez un crédit de compte au prorata qui sera automatiquement appliqué à votre prochaine facture." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Protégé par courriel" + }, + "invalidSendPassword": { + "message": "Mot de passe Send invalide" } } diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index caf617cdaf8..efbea7f59c0 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Enderezo de correo electrónico" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Número de teléfono" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 9ee43cb029a..d2759d27e93 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "אין יישומים קריטיים בסיכון" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "מודיעין גישות" }, @@ -250,6 +268,9 @@ "application": { "message": "יישום" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "סיסמאות בסיכון" }, @@ -302,7 +323,7 @@ } }, "atRiskMemberDescription": { - "message": "These members are logging into critical applications with weak, exposed, or reused passwords." + "message": "חברים אלה נכנסו אל יישומים עם סיסמאות חלשות, חשופות, או משומשות." }, "atRiskMembersDescriptionNone": { "message": "אין חברים שנכנסו אל יישומים עם סיסמאות חלשות, חשופות, או משומשות." @@ -586,6 +607,9 @@ "email": { "message": "אימייל" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "טלפון" }, @@ -1365,6 +1389,12 @@ "no": { "message": "לא" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "מיקום" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "החיוב הבא" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "תוכנית" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "מדיניות ארגון אחת או יותר מונעת ממך מלייצא את הכספת האישית שלך." }, - "activateAutofill": { - "message": "הפעל מילוי אוטומטי" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "הפעל את הגדרת המילוי האוטומטי בעת טעינת עמוד בהרחבת הדפדפן עבור כל החברים הקיימים והחדשים." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "אתרים פרוצים או לא מהימנים יכולים לנצל מילוי אוטומטי בעת טעינת עמוד." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "למד עוד על מילוי אוטומטי" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "בחר סוג SSO" @@ -9395,7 +9428,7 @@ "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, "memberDecryptionOptionTdeDescPart2": { - "message": "", + "message": "policy,", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, "memberDecryptionOptionTdeDescLink2": { @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "שלח נתוני אירועי כספת אל מופע ה־Datadog שלך" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "שמירת האינטגרציה נכשלה. נא לנסות שוב מאוחר יותר." }, @@ -10543,6 +10579,12 @@ "index": { "message": "אינדקס" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "בחר תוכנית" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden ינסה לדרוש את הדומיין 3 פעמים במהלך 72 השעות הראשונות. אם לא ניתן לדרוש את הדומיין, בדוק את רשומת ה־DNS במארח שלך ודרוש באופן ידני. הדומיין יוסר מהארגון שלך תוך 7 ימים אם הוא לא נדרש." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ אינו נדרש. בדוק את רשומות ה־DNS שלך.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "נדרש" }, - "domainStatusUnderVerification": { - "message": "תחת אימות" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "דרוש דומיין כדי להיות הבעלים של חשבונות חברים. עמוד מזהה ה־SSO ידולג במהלך כניסה עבור חברים עם דומיינים שנדרשו ומנהלים יוכלו למחוק חשבונות שנדרשו." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "אמת כעת." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "אחסון נוסף ב־GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index 342672dafc7..b6cbd9fb3a7 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "ईमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "फोन" }, @@ -1365,6 +1389,12 @@ "no": { "message": "नहीं" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index 681a2b94fd9..1567bbf14f2 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nema kritičnih aplikacija u opasnosti" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Pristup inteligenciji" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplikacija" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Rizične lozinke" }, @@ -586,6 +607,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokacija" }, @@ -1750,7 +1780,7 @@ "message": "Nema članova za prikaz." }, "noMembersToExport": { - "message": "There are no members to export." + "message": "Nema članova za izvoz." }, "noEventsInList": { "message": "Nema događaja za prikaz." @@ -2541,7 +2571,7 @@ "message": "Omogućeno" }, "optionEnabled": { - "message": "Enabled" + "message": "Uključeno" }, "restoreAccess": { "message": "Vrati pristup" @@ -2641,7 +2671,7 @@ "message": "Ključ" }, "unnamedKey": { - "message": "Unnamed key" + "message": "Neimenovani ključ" }, "twoStepAuthenticatorEnterCodeV2": { "message": "Kôd za provjeru" @@ -3153,7 +3183,7 @@ "message": "Za ponovni pristup svojoj arhivi, ponovno pokreni Premium pretplatu. Ako urediš detalje arhivirane stavke prije ponovnog pokretanja, ona će biti vraćena u tvoj trezor." }, "itemRestored": { - "message": "Item has been restored" + "message": "Stavka je vraćena" }, "restartPremium": { "message": "Ponovno Pokreni Premium" @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Sljedeća naplata" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Paket" }, @@ -5195,10 +5228,10 @@ "description": "This is a verb. ex. 'Fix The Car'" }, "fixEncryption": { - "message": "Fix encryption" + "message": "Popravi šifriranje" }, "fixEncryptionTooltip": { - "message": "This file is using an outdated encryption method." + "message": "Ova datoteka koristi zastarjelu metodu šifriranja." }, "attachmentUpdated": { "message": "Privitak ažuriran" @@ -5207,7 +5240,7 @@ "message": "Postoje stari privitci u tvom trezoru koje je potrebno popraviti prije rotacije ključa za šifriranje." }, "itemsTransferred": { - "message": "Items transferred" + "message": "Stavke prenesene" }, "yourAccountsFingerprint": { "message": "Jedinstvena fraza tvog računa", @@ -5419,10 +5452,10 @@ "message": "Vrati odabrano" }, "archivedItemRestored": { - "message": "Archived item restored" + "message": "Arhivirana stavka vraćena" }, "archivedItemsRestored": { - "message": "Archived items restored" + "message": "Arhivirane stavke vraćene" }, "restoredItem": { "message": "Stavka vraćena" @@ -5626,13 +5659,13 @@ "message": "Send je uspješno stvoren!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6385,10 +6418,10 @@ "message": "Oporavak računa uključen" }, "enrolled": { - "message": "Enrolled" + "message": "Učlanjen" }, "notEnrolled": { - "message": "Not enrolled" + "message": "Neučlanjen" }, "withdrawAccountRecovery": { "message": "Isključi oporavak računa" @@ -6568,7 +6601,7 @@ "message": "Uspješno ponovno pozvano" }, "bulkReinviteSuccessToast": { - "message": "$COUNT$ users re-invited", + "message": "Korisnika ponovno pozvano: $COUNT$", "placeholders": { "count": { "content": "$1", @@ -6577,7 +6610,7 @@ } }, "bulkReinviteLimitedSuccessToast": { - "message": "$LIMIT$ of $SELECTEDCOUNT$ users re-invited. $EXCLUDEDCOUNT$ were not invited due to the $LIMIT$ invite limit.", + "message": "$LIMIT$ od $SELECTEDCOUNT$ korisnika ponovno pozvano. $EXCLUDEDCOUNT$ nije pozvatno zbog ograničenja poziva ($LIMIT$).", "placeholders": { "limit": { "content": "$1", @@ -6914,7 +6947,7 @@ "message": "Istek trezora nije unutar zadanog vremena." }, "disableExport": { - "message": "Remove export" + "message": "Ukloni izvoz" }, "disablePersonalVaultExportDescription": { "message": "Onemogućuje korisnicima izvoz osobnog trezora." @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Jedno ili više pravila organizacija onemogućuje izvoz osobnog trezora." }, - "activateAutofill": { - "message": "Aktiviraj auto-ispunu" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktivira auto-ispunu kod učitavanja stranice u dodatku za preglednik za sve postojeće i nove korisnike." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Ugrožene ili nepouzdane web stranice mogu iskoristiti auto-ispunu prilikom učitavanja stranice." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Saznaj više o auto-ispuni" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Odaberi vrstu SSO" @@ -9583,7 +9616,7 @@ "message": "Potrebna je SSO prijava" }, "emailRequiredForSsoLogin": { - "message": "Email is required for SSO" + "message": "Za SSO je potrebna e-pošta" }, "selectedRegionFlag": { "message": "Zastava odabrane regije" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Pošalji podatke o događajima trezora svojoj Datadog instanci" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Spremanje integracije nije uspjelo. Pokušaj ponovno kasnije." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Indeks" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Odaberi plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden će pokušati potvrditi domenu 3 puta tijekom prva 72 sata. Ako se domena ne može potvrditi, provjeri DNS zapis na svom poslužitelju i ručno potvrdi. Domena će, ako se ne potvrdi, biti uklonjena iz vaše organizacije nakon 7 dana." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nije potvrđena. Provjeri DNS zapise.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Potvrđena" }, - "domainStatusUnderVerification": { - "message": "Provjera u tijeku" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Potvrdi domenu za vlasništvo nad članskim računima. Stranica za SSO identifikator bit će preskočena tijekom prijave za članove sa potvrđenim domenama, a administratori će moći izbrisati zatražene račune." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Potvrdi sada." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Dodati GB pohrane" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index d65fff35538..404cfc54c19 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nincsenek veszélyben levő kritikus alkalmazások." }, + "critical": { + "message": "Kritikus ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Nem-kritikus ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Elérés intelligencia" }, @@ -250,6 +268,9 @@ "application": { "message": "Alkalmazás" }, + "applications": { + "message": "Alkalmazások" + }, "atRiskPasswords": { "message": "Veszélyes jelszavak" }, @@ -586,6 +607,9 @@ "email": { "message": "Email cím" }, + "emails": { + "message": "Email címek" + }, "phone": { "message": "Telefonszám" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nem" }, + "noAuth": { + "message": "Bárki ezzel a hivatkozással" + }, + "anyOneWithPassword": { + "message": "Bárki az általam beállított jelszóval" + }, "location": { "message": "Hely" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Következő terhelés" }, + "nextChargeDate": { + "message": "Következő terhelés dátum" + }, "plan": { "message": "Csomag" }, @@ -5626,13 +5659,13 @@ "message": "A Send sikeresen létrejött!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Másoljuk és osszuk meg ezt a Send hivatkozást. Megtekinthetik a megadott személyek a következő $TIME$ intervallumban.", + "sendCreatedDescriptionV2": { + "message": "Másoljuk és osszuk meg ezt a Send elem hivatkozást. A Send elem bárki számára elérhető lesz, aki rendelkezik a hivatkozással a következő $TIME$ alatt.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Egy vagy több szervezeti házirend tiltja a személyes széf exportálását." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Automatikus kitöltés bekapcsolása" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Bekapcsolja az automatikus kitöltést az oldalbetöltési beállításokkal a böngésző bővítményben minden meglévő és új tag esetében." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "A veszélyeztetett vagy nem megbízható webhelyek kihasználhatják az oldal betöltéskor végrehajtott automatikus kitöltést." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "További információ az automatikus kitöltésről" }, "selectType": { @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Széf eseményadatok küldése a Datadog példánynak" }, + "huntressEventIntegrationDesc": { + "message": "Eseményadatok küldése a Huntress SIEM éldánynak" + }, "failedToSaveIntegration": { "message": "Nem sikerült menteni az integrációt. Próbáljuk újra később." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP eseménygyűjtő webcím" + }, + "httpEventCollectorToken": { + "message": "HTTP eseménygyűjtő vezérjel" + }, "selectAPlan": { "message": "Előfizetés kiválasztása" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "A Bitwarden az első 72 óra során 3 alkalommal kísérli meg a tartomány ellenőrzését. Ha a tartomány nem ellenőrizhető, ellenőrizésre kerül a DNS rekordt a kiszolgálón és az ellenőrzés manuálisan történik. A tartomány 7 napon belül eltávolításra kerül, ha nem kerül igénylésre." }, + "automaticDomainClaimProcess1": { + "message": "A Bitwarden 72 órán belül megkísérli igényelni a tartományt. Ha a tartomány nem igényelhető, ellenőrizzük DNS-rekordot és igényeljünk manuálisan. A nem igényelt tartományok 7 nap múlva eltávolításra kerülnek." + }, + "automaticDomainClaimProcess2": { + "message": "Az igénylést követően az igényelt tartományokkal rendelkező meglévő tagok emailt kapnak: " + }, + "accountOwnershipChange": { + "message": "fiók tulajdonos váltás" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nincs igényelve. Ellenőrizzük a DNS rekordot.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Igényelve" }, - "domainStatusUnderVerification": { - "message": "Ellenőrzés alatt" + "domainStatusPending": { + "message": "Függő" }, "claimedDomainsDescription": { "message": "Tartomány igénylése tagfiókok birtoklására. Az igényelt tartományokkal rendelkező tagok bejelentkezése során az SSO azonosító oldal kihagyásra kerül és az adminisztrátorok törölhetik az igényelt fiókokat." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Ellenőrzés most" }, + "unlockWithPasskey": { + "message": "Hozzáférési kulcs" + }, + "prfUnlockFailed": { + "message": "Nem sikerült a feloldás a hozzéférési kulccsal. Próbáljuk újra vagy használjunk más feloldási metódust." + }, + "noPrfCredentialsAvailable": { + "message": "A feloldáshoz nem állnak rendelkezésre PRF kompatibilis hozzáférési kucsok." + }, "additionalStorageGB": { "message": "Kiegészítő tárhely (GB)" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "A titkosított tárhely összes $GB$ mérete felhasználásra került. A fájlok tárolásának folytatásához adjunk hozzá további tárhelyet." }, + "whoCanView": { + "message": "Ki láthatja" + }, + "specificPeople": { + "message": "Adott személyek" + }, + "emailVerificationDesc": { + "message": "A Send hivatkozás megosztása után a személyeknek ellenőrizniük kell email címüket egy kóddal a Send megtekintéséhez." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Írjunk be több email címet vesszővel elválasztva." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "A tárhely eltávolításakor arányos számlajóváírást kapunk, amely automatikusan a következő számlára kerül." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Védett email cím" + }, + "invalidSendPassword": { + "message": "Érvénytelen a Send jelszó." } } diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index 43cef248d13..3a8e798d5d1 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Tidak ada aplikasi penting berisiko" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Akses Pintar" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplikasi" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Sandi berisiko" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Tidak" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokasi" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Satu atau beberapa kebijakan organisasi mencegah Anda mengekspor brankas individual Anda." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Pilih Tipe SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index d025d5034c4..c2fbee034aa 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nessuna applicazione critica a rischio" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Intelligence sugli accessi" }, @@ -250,6 +268,9 @@ "application": { "message": "Applicazione" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Password a rischio" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefono" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Luogo" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Prossimo addebito" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Piano" }, @@ -5626,13 +5659,13 @@ "message": "Send creato con successo!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copia e condividi questo link Send: potrà essere visualizzato dalle persone che hai specificato per le prossime $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copia e condividi questo link di invio. Sarà disponibile a chiunque ne sia a disposizione la prossima $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Una o più politiche dell'organizzazione ti impediscono di esportare la tua cassaforte personale." }, - "activateAutofill": { - "message": "Attiva riempimento automatico" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Attiva l'impostazione di riempimento automatico al caricamento della pagina nella estensione per il browser per tutti i membri esistenti e nuovi." + "activateAutofillPolicyDescription": { + "message": "Attiva il completamento automatico sulla pagina di caricamento delle impostazioni dell'estensione browser per tutti i membri nuovi ed esistenti." }, - "experimentalFeature": { - "message": "Siti compromessi potrebbero sfruttare il riempimento automatico al caricamento della pagina." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Ulteriori informazioni" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Seleziona tipo di SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Invia i dati dell'evento della cassaforte all'istanza di Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Impossibile salvare l'integrazione. Riprova più tardi." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Indice" }, + "httpEventCollectorUrl": { + "message": "URL del collettore di eventi HTTP" + }, + "httpEventCollectorToken": { + "message": "Token del collettore eventi HTTP" + }, "selectAPlan": { "message": "Seleziona un piano" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden tenterà di verificare il dominio 3 volte durante le prossime 72 ore. Se il dominio non può essere acquisito, controlla il record DNS del tuo servizio di hosting e procedi manualmente. Il dominio sarà rimosso dall'organizzazione dopo 7 giorni se la procedura non andrà a buon fine." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden proverà ad ottenere il dominio nelle prossime 72 ore. Se il dominio non può essere ottenuto, verifica il tuo record DNS ed ottienilo manualmente. I domini non ottenuti vengono rimossi dopo 7 giorni." + }, + "automaticDomainClaimProcess2": { + "message": "Una volta ottenuto, i membri con domini riservati verranno contattati via e-mail per " + }, + "accountOwnershipChange": { + "message": "modifica della proprietà dell'account" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ non verificato. Controlla il record DNS.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Verificato" }, - "domainStatusUnderVerification": { - "message": "In attesa di verifica" + "domainStatusPending": { + "message": "In attesa" }, "claimedDomainsDescription": { "message": "Richiedi un dominio per avere a disposizione account membri. La pagina SSO sarà saltata durante il login per i membri con domini rivendicati e gli amministratori saranno in grado di eliminare gli account rivendicati." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verifica adesso." }, + "unlockWithPasskey": { + "message": "Sblocca con passkey" + }, + "prfUnlockFailed": { + "message": "Impossibile sbloccare con passkey. Riprova o utilizza un altro metodo." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Spazio di archiviazione aggiuntivo (GB)" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "Hai usato tutti i $GB$ GB del tuo spazio di archiviazione crittografato. Per archiviare altri file, aggiungi altro spazio." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Quando rimuovi spazio di archiviazione, riceverai un credito che sarà automaticamente applicato al tuo prossimo pagamento." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protetta" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index 11e65d9d738..2920a2e98b6 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "危険にさらされた重要なアプリケーションはありません" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "アクセス インテリジェンス" }, @@ -250,6 +268,9 @@ "application": { "message": "アプリ" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "リスクがあるパスワード" }, @@ -586,6 +607,9 @@ "email": { "message": "メールアドレス" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "電話番号" }, @@ -1365,6 +1389,12 @@ "no": { "message": "いいえ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "場所" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "1 つ以上の組織ポリシーにより、個人の保管庫をエクスポートできません。" }, - "activateAutofill": { - "message": "自動入力を有効にする" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "すべての既存および新規メンバーのブラウザ拡張機能のページ読み込み設定で、自動入力を有効にします。" + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "ウイルス感染したり信頼できないウェブサイトは、ページの読み込み時の自動入力を悪用できてしまいます。" + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "自動入力についての詳細" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "SSO のタイプを選択" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "プランを選択" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index bb9d73493e2..28fb7e7a76d 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "ელ-ფოსტა" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ტელეფონი" }, @@ -1365,6 +1389,12 @@ "no": { "message": "არა" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index 53229a365bb..47af931229d 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index 39766ec7268..c3c3035fdcb 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "ಇಮೇಲ್" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ಫೋನ್‌" }, @@ -1365,6 +1389,12 @@ "no": { "message": "ಇಲ್ಲ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index 81eb7345714..add9f3bc8ae 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "이메일" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "전화번호" }, @@ -1365,6 +1389,12 @@ "no": { "message": "아니오" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "SSO 유형 선택" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index 53b7236b8de..718439c721e 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nav riskam pakļautu būtisku lietotņu" }, + "critical": { + "message": "Būtiski ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Nav būtiski ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Piekļuves inteliģence" }, @@ -250,6 +268,9 @@ "application": { "message": "Lietotne" }, + "applications": { + "message": "Lietotnes" + }, "atRiskPasswords": { "message": "Riskam pakļautās paroles" }, @@ -586,6 +607,9 @@ "email": { "message": "E-pasts" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Tālrunis" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nē" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Atrašanās vieta" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Nākamais maksājums" }, + "nextChargeDate": { + "message": "Nākamās apmaksas datums" + }, "plan": { "message": "Plāns" }, @@ -5626,13 +5659,13 @@ "message": "Send tika veiksmīgi izveidots.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Ievieto starpliktuvē un kopīgo šī Send saiti! To $TIME$ no šī brīža var apskatīt cilvēki, kurus norādīji.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Viens vai vairāki apvienības nosacījumi neļauj izgūt privātās glabātavas saturu." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Iespējot automātisko aizpildi" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Pārlūka paplašinājumā iespējot automātisko aizpildi lapas ielādes brīdī visiem esošajiem un jaunajiem dalībniekiem." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Pārveidotās vai neuzticamās tīmekļvietnēs automātiskā aizpilde lapas ielādes laikā var tikt ļaunprātīgi izmantota." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Uzzināt vairāk par automātisko aizpildi" }, "selectType": { @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Nosūtīt glabātavas notikumu datus uz savu Datadog serveri" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Neizdevās saglabāt iekļaušanu. Lūgums vēlāk mēģināt vēlreiz." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Indekss" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Atlasīt plānu" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden mēģinās pārbaudīt domēnu 3 reizes pirmajās 72 stundās. Ja domēnu nevarēs pieteikt, būs jāpārbauda DNS ieraksts saimniekdatorā un tas pašrocīgi jāpiesaka. Domēns tiks noņemts no apvienības pēc 7 dienām, ja tas nebūs pieteikts." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nav pieteikts. Jāpārbauda DNS ieraksts.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Pieteikts" }, - "domainStatusUnderVerification": { - "message": "Apliecināšanā" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Iegūsti domēna piederību, lai iegūtu dalībnieku kontu īpašumtiesības. SSO identificētāja lapa tiks izlaista, kad pieteiksies dalībnieki, kuru kontu piederība ir atkarīga no domēna, un pārvaldītāji varēs izdzēst šāda veida kontus." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Apliecini tagad!" }, + "unlockWithPasskey": { + "message": "Atslēgt ar piekļuves atslēgu" + }, + "prfUnlockFailed": { + "message": "Neizdevās atslēgt ar piekļuves atslēgu. Lūgums mēģināt vēlreiz vai izmantot citu atslēgšanas veidu." + }, + "noPrfCredentialsAvailable": { + "message": "Atslēgšanai nav pieejama neviena PRF iespējota piekļuves atslēga." + }, "additionalStorageGB": { "message": "Papildu krātuve GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Kad noņemsi krātuvi, saņemsi konta kredītu noteiktā apjomā, kas tiks automātiski izmantots nākamajā rēķinā." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "E-pasts aizsargāts" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index 9cbcf27107c..2fe84b98d75 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "ഇമെയിൽ" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ഫോൺ" }, @@ -1365,6 +1389,12 @@ "no": { "message": "അല്ല" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index da407d6e6dd..fd55de6cab7 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "कोणतेही महत्त्वाचे अ‍ॅप्लिकेशन्स धोक्यात नाहीत" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "अ‍ॅक्सेस इंटेलिजेंस" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "धोकादायक पासवर्ड" }, @@ -586,6 +607,9 @@ "email": { "message": "ईमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index 53229a365bb..47af931229d 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index d50f3d30f42..06b35a2977c 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Program" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sted" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "En eller flere regler i organisasjonsoppsettet forhindrer deg i å eksportere ditt personlige hvelv." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Velg SSO-type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index d879a6ca6cf..6887e331c4e 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "इमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "फोन" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "सबै अवस्थित र नयाँ सदस्यहरूको लागि ब्राउजर एक्सटेन्सनको पृष्ठ लोड सेटिङमा स्वत: भरण विकल्प सक्रिय गर्नुहोस्।" + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index b537e42bba4..842df8eb3e0 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Geen kritische applicaties in gevaar" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Toegangsintelligentie" }, @@ -250,6 +268,9 @@ "application": { "message": "Applicatie" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Wachtwoorden in gevaar" }, @@ -586,6 +607,9 @@ "email": { "message": "E-mailadres" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoonnummer" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nee" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Locatie" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Volgende betaling" }, + "nextChargeDate": { + "message": "Volgende datum van betaling" + }, "plan": { "message": "Pakket" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Kopieer en deel deze Send-link. De Send is beschikbaar voor iedereen met de link voor de volgende $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5916,35 +5949,35 @@ } }, "centralizeDataOwnership": { - "message": "Centralize organization ownership" + "message": "Centraliseer organisatie-eigendom" }, "centralizeDataOwnershipDesc": { - "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + "message": "Alle items van leden worden eigendom van en beheerd door de organisatie. Beheerders en eigenaren zijn vrijgesteld. " }, "centralizeDataOwnershipContentAnchor": { - "message": "Learn more about centralized ownership", + "message": "Meer informatie over gecentraliseerd eigendom", "description": "This will be used as a hyperlink" }, "benefits": { "message": "Voordelen" }, "centralizeDataOwnershipBenefit1": { - "message": "Gain full visibility into credential health, including shared and unshared items." + "message": "Krijg volledige zichtbaarheid in de gezondheid van inloggegevens, inclusief gedeelde en niet-gedeelde items." }, "centralizeDataOwnershipBenefit2": { - "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + "message": "Eenvoudig items tijdens het offboarden van leden en opvolging verplaatsen, verzekerd dat er geen toegangsgaten zijn." }, "centralizeDataOwnershipBenefit3": { - "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + "message": "Geef alle gebruikers een toegewijde \"Mijn Items\"-ruimte voor het beheren van hun eigen inloggegevens." }, "centralizeDataOwnershipWarningTitle": { - "message": "Prompt members to transfer their items" + "message": "Vraagt de leden om hun items over te brengen" }, "centralizeDataOwnershipWarningDesc": { - "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + "message": "Als leden items in hun individuele kluis hebben, worden ze gevraagd deze over te dragen naar de organisatie of te vertrekken. Als ze vertrekken, wordt hun toegang ingetrokken maar kan deze op elk moment worden hersteld." }, "centralizeDataOwnershipWarningLink": { - "message": "Learn more about the transfer" + "message": "Meer informatie over de overstap" }, "organizationDataOwnership": { "message": "Gegevenseigendom van organisatie afdwingen" @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Organisatiebeleid voorkomt dat je je persoonlijke kluis exporteert." }, - "activateAutofill": { - "message": "Automatisch invullen activeren" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activeer de automatisch invullen wanneer de pagina geladen is instelling in de browser extensie voor bestaande en nieuwe gebruikers." + "activateAutofillPolicyDescription": { + "message": "Activeer de \"automatisch invullen wanneer de pagina geladen is\"-instelling in de browserextensie voor alle bestaande en nieuwe gebruikers." }, - "experimentalFeature": { - "message": "Gehackte of onbetrouwbare websites kunnen automatisch invullen bij laden van pagina misbruiken." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Meer info over automatisch invullen" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Selecteer Type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Stuur gebeurtenisgegevens van je kluis naar je Datadog-instance" }, + "huntressEventIntegrationDesc": { + "message": "Stuur eventgegevens naar je Huntress SIEM-instantie" + }, "failedToSaveIntegration": { "message": "Opslaan van integratie mislukt. Probeer het later opnieuw." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Selecteer een plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden probeert het domein gedurende de eerste 72 uur driemaal te verifiëren. Als het domein niet geverifieerd kan worden, controleer dan het DNS-record bij je host en verifieer handmatig. Het domein wordt binnen 7 dagen verwijderd uit je organisatie als het niet geverifieerd is." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden probeert het domein binnen 72 uur te claimen. Als het domein niet kan worden geclaimd, controleer dan je DNS-record en claim handmatig. Niet-geclaimde domeinen worden na 7 dagen verwijderd." + }, + "automaticDomainClaimProcess2": { + "message": "Eenmaal geclaimd, zullen bestaande leden met geclaimde domeinen gemaild worden over de " + }, + "accountOwnershipChange": { + "message": "accounteigendom wijziging" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ niet geverifieerd. Controleer je DNS-records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Geverifieerd" }, - "domainStatusUnderVerification": { - "message": "Gebruikersverificatie" + "domainStatusPending": { + "message": "In behandeling" }, "claimedDomainsDescription": { "message": "Claim een domein voor eigendom van ledenaccounts. De SSO-identificatiepagina wordt overgeslagen tijdens het inloggen voor leden met geclaimde domeinen en beheerders kunnen geclaimde accounts verwijderen." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Nu verifiëren." }, + "unlockWithPasskey": { + "message": "Ontgrendelen met passkey" + }, + "prfUnlockFailed": { + "message": "Ontgrendelen met passkey mislukt. Probeer het opnieuw of gebruik een andere ontgrendelingsmethode." + }, + "noPrfCredentialsAvailable": { + "message": "Geen PRF-ingeschakelde passkeys beschikbaar om te ontgrendelen." + }, "additionalStorageGB": { "message": "Extra opslagruimte (GB)" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Wanneer je opslag verwijdert, krijg je op je volgende rekening automatisch pro-rata rekeningkrediet." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "E-mail beveiligd" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index 626abb32cb7..893de2f2f33 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index 53229a365bb..47af931229d 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 7cb555cf6fa..a7725961a33 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Brak zagrożonych aplikacji krytycznych" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Dostęp do informacji" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplikacja" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Zagrożone hasła" }, @@ -586,6 +607,9 @@ "email": { "message": "Adres e-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokalizacja" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Co najmniej jedna zasada organizacji uniemożliwia wyeksportowanie osobistego sejfu." }, - "activateAutofill": { - "message": "Włącz autouzupełnianie" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktywuj autouzupełnianie podczas wczytywania strony w rozszerzeniu przeglądarki dla wszystkich istniejących i nowych użytkowników." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Zaatakowane lub niezaufane witryny internetowe mogą wykorzystać funkcję autouzupełniania podczas wczytywania strony, aby wyrządzić szkody." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Dowiedz się więcej o autouzupełnianiu" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Wybierz rodzaj logowania jednokrotnego SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Wybierz plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden spróbuje zgłosić domenę 3 razy w ciągu pierwszych 72 godzin. Jeśli nie można zgłosić domeny, sprawdź rekord DNS w swoim serwerze i sprawdź go ręcznie. Domena zostanie usunięta z Twojej organizacji w ciągu 7 dni, jeśli nie zostanie zgłoszona." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nie została zgłoszona. Sprawdź swój rekord DNS.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Zgłoszono" }, - "domainStatusUnderVerification": { - "message": "W trakcie weryfikacji" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Zgłoś domenę, aby posiadać konta członków. Strona identyfikatora SSO zostanie pominięta podczas logowania dla członków z zadeklarowanymi domenami, a administratorzy będą mogli usuwać zadeklarowane konta." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 2d4a3d72123..53461da1741 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nenhum aplicativo crítico em risco" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Inteligência de acesso" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplicativo" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Senhas em risco" }, @@ -586,6 +607,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Não" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Localização" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Próxima cobrança" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plano" }, @@ -5626,13 +5659,13 @@ "message": "Send criado com sucesso!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copie e compartilhe este link do Send. Ele pode ser visto pelas pessoas que você especificou pelos próximos $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copie e compartilhe este link do Send. O Send ficará disponível para qualquer um com o link por $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Uma ou mais políticas da organização impedem que você exporte seu cofre individual." }, - "activateAutofill": { - "message": "Ativar preenchimento automático" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Ative a configuração de preenchimento automático no carregamento da página na extensão do navegador para todos os membros existentes e novos." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Sites comprometidos ou não confiáveis podem tomar vantagem do preenchimento automático ao carregar a página." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Saiba mais sobre o preenchimento automático" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Selecionar tipo de SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Envie dados de eventos do cofre a sua instância do Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Envie dados de eventos para sua instância do Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Falha ao salvar a integração. Tente novamente mais tarde." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Índice" }, + "httpEventCollectorUrl": { + "message": "URL do coletor de eventos HTTP" + }, + "httpEventCollectorToken": { + "message": "Token do coletor de eventos HTTP" + }, "selectAPlan": { "message": "Selecione um plano" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "O Bitwarden tentará reivindicar o domínio 3 vezes durante as primeiras 72 horas. Se o domínio não poder ser reivindicado, confira o registro de DNS no seu servidor e reivindique manualmente. Se não for reivindicado, o domínio será removido da sua organização em 7 dias." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ não reivindicado. Confira os seus registros de DNS.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Reivindicado" }, - "domainStatusUnderVerification": { - "message": "Em verificação" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Reivindique um domínio para ser o proprietário das contas dos membros. A página do identificador do SSO será pulada durante a autenticação dos membros com os domínios reivindicados, e os administradores poderão apagar contas reivindicadas." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verifique agora." }, + "unlockWithPasskey": { + "message": "Desbloquear com chave de acesso" + }, + "prfUnlockFailed": { + "message": "Falha no desbloqueio com a chave de acesso. Tente novamente ou use outro método de desbloqueio." + }, + "noPrfCredentialsAvailable": { + "message": "Nenhuma chave de acesso com PRF está disponível para desbloqueio." + }, "additionalStorageGB": { "message": "GB de armazenamento adicional" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "Você usou todos os $GB$ GB do seu armazenamento criptografado. Para continuar armazenando arquivos, adicione mais armazenamento." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Quando você remover o armazenamento, você receberá um crédito de conta proporcional que irá automaticamente para sua próxima fatura." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "E-mail protegido" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index 43012a2ab3f..9436fdb1f8f 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Não há aplicações críticas em risco" }, + "critical": { + "message": "Críticas ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Não críticas ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Inteligência de Acesso" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplicação" }, + "applications": { + "message": "Aplicações" + }, "atRiskPasswords": { "message": "Palavras-passe em risco" }, @@ -586,6 +607,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-mails" + }, "phone": { "message": "Telefone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Não" }, + "noAuth": { + "message": "Qualquer pessoa com o link" + }, + "anyOneWithPassword": { + "message": "Qualquer pessoa com uma palavra-passe definida por si" + }, "location": { "message": "Localização" }, @@ -3156,7 +3186,7 @@ "message": "O item foi restaurado" }, "restartPremium": { - "message": "Reiniciar Premium" + "message": "Reiniciar o Premium" }, "additionalStorageGb": { "message": "Armazenamento adicional (GB)" @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Próxima cobrança" }, + "nextChargeDate": { + "message": "Próxima data de cobrança" + }, "plan": { "message": "Plano" }, @@ -5626,13 +5659,13 @@ "message": "Send criado com sucesso!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copie e partilhe este link do Send. Pode ser visualizado pelas pessoas que especificou durante os próximos $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copie e partilhe este link do Send. O Send estará disponível para qualquer pessoa com o link durante os próximos $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Uma ou mais políticas da organização impedem-no de exportar o seu cofre pessoal." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Ativar o preenchimento automático" }, - "activateAutofillPolicyDesc": { - "message": "Ative a definição de preenchimento automático ao carregar a página na extensão do navegador para todos os membros existentes e novos." + "activateAutofillPolicyDescription": { + "message": "Ative a definição de preenchimento automático ao carregar a página na extensão do navegador para todos os membros atuais e novos." }, - "experimentalFeature": { - "message": "Os sites comprometidos ou não fiáveis podem explorar o preenchimento automático ao carregar a página." + "autofillOnPageLoadExploitWarning": { + "message": "Os sites comprometidos ou não confiáveis podem explorar o preenchimento automático ao carregar a página." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Saber mais sobre o preenchimento automático" }, "selectType": { @@ -8035,7 +8068,7 @@ } }, "inputMinValue": { - "message": "O valor do campo tem de ser, pelo menos, $MIN$ caracteres.", + "message": "O valor introduzido deve ser, no mínimo, $MIN$.", "placeholders": { "min": { "content": "$1", @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Envie dados de eventos do cofre para a sua instância da Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Enviar dados de eventos para a sua instância Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Falha ao guardar a integração. Por favor, tente novamente mais tarde." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Índice" }, + "httpEventCollectorUrl": { + "message": "URL do coletor de eventos HTTP" + }, + "httpEventCollectorToken": { + "message": "Token do coletor de eventos HTTP" + }, "selectAPlan": { "message": "Selecionar um plano" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "O Bitwarden tentará reivindicar o domínio 3 vezes durante as primeiras 72 horas. Se o domínio não puder ser reivindicado, verifique o registo DNS no seu anfitrião e reivindique manualmente. O domínio será removido da sua organização em 7 dias se não for reivindicado." }, + "automaticDomainClaimProcess1": { + "message": "O Bitwarden tentará reclamar o domínio no prazo de 72 horas. Caso não seja possível reclamar o domínio, verifique o registo DNS e faça a reclamação manualmente. Os domínios não reclamados são removidos após 7 dias." + }, + "automaticDomainClaimProcess2": { + "message": "Após a reclamação, os membros existentes com domínios reclamados serão notificados por e-mail sobre a " + }, + "accountOwnershipChange": { + "message": "alteração da titularidade da conta" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ não reivindicado. Verifique o seu registo DNS.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Reivindicado" }, - "domainStatusUnderVerification": { - "message": "Sob verificação" + "domainStatusPending": { + "message": "Pendente" }, "claimedDomainsDescription": { "message": "Reivindique um domínio para possuir contas de membros. A página do identificador SSO será ignorada durante o início de sessão para membros com domínios reivindicados e os administradores poderão eliminar contas reivindicadas." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verificar agora." }, + "unlockWithPasskey": { + "message": "Desbloquear com chave de acesso" + }, + "prfUnlockFailed": { + "message": "Não foi possível desbloquear com a chave de acesso. Por favor, tente novamente ou utilize outro método de desbloqueio." + }, + "noPrfCredentialsAvailable": { + "message": "Não estão disponíveis chaves de acesso com PRF ativado para o desbloqueio." + }, "additionalStorageGB": { "message": "Armazenamento adicional (GB)" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "Utilizou os $GB$ GB do seu armazenamento encriptado. Para continuar a guardar ficheiros, adicione mais espaço de armazenamento." }, + "whoCanView": { + "message": "Quem pode ver" + }, + "specificPeople": { + "message": "Pessoas específicas" + }, + "emailVerificationDesc": { + "message": "Após partilhar este Send através do link, os indivíduos terão de verificar o e-mail com um código para poderem ver este Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Introduza vários e-mails, separados por vírgula." + }, + "emailPlaceholder": { + "message": "utilizador@bitwarden.com , utilizador@acme.com" + }, "whenYouRemoveStorage": { "message": "Ao remover espaço de armazenamento, receberá um crédito proporcional na conta, que será automaticamente aplicado na sua próxima fatura." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "E-mail protegido" + }, + "invalidSendPassword": { + "message": "Palavra-passe do Send inválida" } } diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index e46e8ddcb5b..3ec09c44e44 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nicio aplicație critică în pericol" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplicație" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nu" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Locație" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Una sau mai multe politici de organizație împiedică exportul seifului individual." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Selectare tip SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index 93670efc081..e865341d64c 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Никакие критичные приложения не подвергаются риску" }, + "critical": { + "message": "Критичные ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Не критичные ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Приложение" }, + "applications": { + "message": "Приложения" + }, "atRiskPasswords": { "message": "Пароли, подверженные риску" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Нет" }, + "noAuth": { + "message": "Любой, у кого есть ссылка" + }, + "anyOneWithPassword": { + "message": "Любой, у кого есть установленный вами пароль" + }, "location": { "message": "Местоположение" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Следующий платеж" }, + "nextChargeDate": { + "message": "Дата следующего платежа" + }, "plan": { "message": "План" }, @@ -5626,13 +5659,13 @@ "message": "Send успешно создана!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Скопируйте и распространите эту ссылку для Send. Она может быть просмотрена указанными вами пользователями в следующие $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Скопируйте и поделитесь этой ссылкой Send. Send будет доступна всем, у кого есть ссылка, в течение следующих $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Одна или несколько политик организации запрещают вам экспортировать личное хранилище." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Активировать автозаполнение" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Включить автозаполнение при загрузке страницы в расширении браузера для всех существующих и новых участников." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Взломанные или недоверенные сайты могут внедрить вредоносный код во время автозаполнения при загрузке страницы." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Узнать больше об автозаполнении" }, "selectType": { @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Отправляйте данные о событиях хранилища в ваш экземпляр Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Отправлять данные о событиях в ваш инстанс Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Не удалось сохранить интеграцию. Пожалуйста, повторите попытку позже." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Индекс" }, + "httpEventCollectorUrl": { + "message": "URL коллектора событий HTTP" + }, + "httpEventCollectorToken": { + "message": "Токен коллектора событий HTTP" + }, "selectAPlan": { "message": "Выберите план" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden попытается зарегистрировать домен 3 раза в течение первых 72 часов. Если домен не удастся зарегистрировать, проверьте запись DNS на вашем хосте и зарегистрируйте вручную. Домен будет удален из вашей организации через 7 дней, если он не будет зарегистрирован." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden попытается заявить права на домен в течение 72 часов. Если домен не может быть заявлен, проверьте свою запись в DNS и подайте заявку вручную. Невостребованные домены удаляются через 7 дней." + }, + "automaticDomainClaimProcess2": { + "message": "После подачи заявки существующим участникам с заявленными доменами будет отправлено электронное письмо с информацией об " + }, + "accountOwnershipChange": { + "message": "изменении владельца аккаунта" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ не зарегистрирован. Проверьте записи DNS.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Зарегистрирован" }, - "domainStatusUnderVerification": { - "message": "Проверяется" + "domainStatusPending": { + "message": "Ожидание" }, "claimedDomainsDescription": { "message": "Заявите права на домен, чтобы владеть аккаунтами членов. Страница идентификатора SSO будет пропущена при пользователей с заявленными доменами, а администраторы смогут удалять заявленные аккаунты." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Подтвердить сейчас." }, + "unlockWithPasskey": { + "message": "Разблокировать при помощи passkey" + }, + "prfUnlockFailed": { + "message": "Не удалось разблокировать с помощью passkey. Пожалуйста, повторите попытку или используйте другой метод разблокировки." + }, + "noPrfCredentialsAvailable": { + "message": "Для разблокировки недоступны passkeys с поддержкой PRF." + }, "additionalStorageGB": { "message": "Дополнительные ГБ хранилища" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "Вы использовали все $GB$ вашего зашифрованного хранилища. Чтобы продолжить хранение файлов, добавьте дополнительное хранилище." }, + "whoCanView": { + "message": "Кто может просматривать" + }, + "specificPeople": { + "message": "Конкретные пользователи" + }, + "emailVerificationDesc": { + "message": "После того, как вы поделитесь ссылкой на Send, пользователю нужно будет подтвердить свой email кодом, чтобы просмотреть эту Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Введите несколько email, разделяя их запятой." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "При удалении хранилища вы получите пропорциональную сумму на свой счет, которая автоматически пойдет на оплату вашего следующего счета." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email защищен" + }, + "invalidSendPassword": { + "message": "Неверный пароль Send" } } diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index 95b0e61e822..af545c77eea 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "වි-තැපෑල" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "දුරකථනය" }, @@ -1365,6 +1389,12 @@ "no": { "message": "නැහැ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index 5ec513ff48d..ecf51e84c0b 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nie sú ohrozené žiadne kritické aplikácie" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Prehľad o prístupe" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplikácia" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Ohrozených hesiel" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefón" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Poloha" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Ďalšia platba" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plán" }, @@ -5626,13 +5659,13 @@ "message": "Send bol úspešne vytvorený!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Skopírujte a zdieľajte tento odkaz na Send. Ľudia ktorých ste zadali môžu Send vidieť najbližších $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Jedna alebo viacero zásad organizácie vám bráni exportovať váš osobný trezor." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktivujte nastavenie automatického vypĺňania pri načítaní stránky v rozšírení pre prehliadač pre všetkých súčasných a nových členov." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Vyberte typ SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Pošlite dáta z denníka udalostí do vašej inštancie Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Nepodarilo sa uložiť integráciu. Prosím skúste to neskôr." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Vyberte plán" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden sa pokúsi privlastniť doménu 3 krát počas prvých 72 hodín. Ak sa doménu nepodarilo privlastniť, skontrolujte DNS záznam u svojho hostiteľa a privlastnite manuálne. Doména bude z organizácie odstránená po 7 dňoch ak nie je privlastnená." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nie je privlastnená. Overte si DNS záznam.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Privlastnená" }, - "domainStatusUnderVerification": { - "message": "Overuje sa" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Privlastnite si doménu, aby ste mohli vlastniť členské účty. Stránka s identifikátorom SSO bude pri prihlásení členov s privlastnenými doménami preskočená a správcovia budú môcť členské účty odstrániť." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Overiť teraz." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Dodatočné úložisko GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "Použili ste všetkých $GB$ GB vášho šifrovaného úložiska. Ak chcete uložiť ďalšie súbory, pridajte viac úložiska." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Ak odstránite úložisko, dostanete na váš účet proporcionálny kredit ktorý sa automaticky použije pri najbližšej faktúre." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email chránený" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index 12734f2fb8a..cbbc276b371 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Analiza dostopa" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -332,7 +353,7 @@ } }, "totalMembers": { - "message": "Total members" + "message": "Skupaj članov" }, "atRiskApplications": { "message": "At-risk applications" @@ -456,10 +477,10 @@ "message": "Zapisek" }, "privateNote": { - "message": "Private note" + "message": "Privaten zapisek" }, "note": { - "message": "Note" + "message": "Zapisek" }, "customFields": { "message": "Polja po meri" @@ -586,6 +607,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -893,7 +917,7 @@ "message": "Zavarovan zapisek" }, "typeNote": { - "message": "Note" + "message": "Zapisek" }, "typeSshKey": { "message": "SSH key" @@ -986,7 +1010,7 @@ "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Nov zapisek", "description": "Header for new note item type" }, "newItemHeaderSshKey": { @@ -1014,7 +1038,7 @@ "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Uredi zapisek", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { @@ -1042,7 +1066,7 @@ "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Poglej zapisek", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { @@ -1132,7 +1156,7 @@ "message": "Copy website" }, "copyNotes": { - "message": "Copy notes" + "message": "Kopiraj zapisek" }, "copyAddress": { "message": "Copy address" @@ -1365,6 +1389,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2306,11 +2336,11 @@ "message": "Orodja" }, "importNoun": { - "message": "Import", + "message": "Uvozi", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Uvozi", "description": "The verb form of the word Import" }, "importData": { @@ -2325,7 +2355,7 @@ "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. (Optional second half: You may need to wait until your administrator confirms your organization membership.)" }, "onboardingImportDataDetailsLoginLink": { - "message": "new login", + "message": "nova prijava", "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new login instead. (Optional second half: You may need to wait until your administrator confirms your organization membership.)" }, "onboardingImportDataDetailsPartTwoNoOrgs": { @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3444,7 +3477,7 @@ "message": "General information" }, "organizationName": { - "message": "Organization name" + "message": "Naziv organizacije" }, "accountOwnedBusiness": { "message": "This account is owned by a business." @@ -3821,7 +3854,7 @@ "message": "Client owner email" }, "owner": { - "message": "Owner" + "message": "Lastnik" }, "ownerDesc": { "message": "Manage all aspects of your organization, including billing and subscriptions" @@ -4685,7 +4718,7 @@ "message": "My organization" }, "organizationInfo": { - "message": "Organization info" + "message": "Organizacija" }, "deleteOrganization": { "message": "Delete organization" @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6268,7 +6301,7 @@ "message": "Send request" }, "addANote": { - "message": "Add a note" + "message": "Dodaj zapisek" }, "bitwardenSecretsManager": { "message": "Bitwarden Secrets Manager" @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Več o samodejnem izpolnjevanju" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -8119,7 +8152,7 @@ "message": "Members" }, "reporting": { - "message": "Reporting" + "message": "Poročanje" }, "numberOfUsers": { "message": "Number of users" @@ -9723,7 +9756,7 @@ "message": "Secrets Manager plan price" }, "passwordManager": { - "message": "Password Manager" + "message": "Upravitelj gesel" }, "freeOrganization": { "message": "Free Organization" @@ -10011,7 +10044,7 @@ "message": "Read release blog" }, "adminConsole": { - "message": "Admin Console" + "message": "Nadzorna plošča" }, "providerPortal": { "message": "Provider Portal" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11428,7 +11482,7 @@ "message": "Item removed from favorites" }, "copyNote": { - "message": "Copy note" + "message": "Kopiraj zapisek" }, "organizationNameMaxLength": { "message": "Organization name cannot exceed 50 characters." @@ -11766,7 +11820,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "generatorNudgeTitle": { - "message": "Quickly create passwords" + "message": "Hitro ustvarite gesla" }, "generatorNudgeBodyOne": { "message": "Easily create strong and unique passwords by clicking on", @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index 18202bbc87a..43b41d22ca6 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Pristupi inteligenciji" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Imejl" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/sr_CY/messages.json b/apps/web/src/locales/sr_CY/messages.json index 7b7876ce303..147780e2c2b 100644 --- a/apps/web/src/locales/sr_CY/messages.json +++ b/apps/web/src/locales/sr_CY/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Нема критичних апликација у ризику" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Приступи интелигенцији" }, @@ -250,6 +268,9 @@ "application": { "message": "Апликација" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Лозинке под ризиком" }, @@ -586,6 +607,9 @@ "email": { "message": "Е-пошта" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Локација" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Следеће пуњење" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "План" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Једна или више полиса ваше организације вас спречава да извезете ваш сеф." }, - "activateAutofill": { - "message": "Активирати ауто-пуњење" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Активирајте ауто-пуњење при учитавању странице на додатку прегледача за све постојеће и нове чланове." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Компромитоване или непоуздане веб локације могу да искористе ауто-пуњење при учитавању странице." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Сазнајте више о ауто-пуњење" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Одабрати тип SSO-а" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Није успело сачувавање интеграције. Покушајте поново касније." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Индекс" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Изаберите пакет" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Захтевано" }, - "domainStatusUnderVerification": { - "message": "Под провером" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Захтевајте домен на сопствени рачуни чланова. Страница SSO идентификатора биће прескочена током пријаве за чланове са захтевним доменима и администратори ће моћи да избрише захтевене рачуне." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index a7ca072cca6..0f1f827c7b5 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Inga kritiska applikationer i riskzonen" }, + "critical": { + "message": "Kritiska ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Inte kritiska ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Applikation" }, + "applications": { + "message": "Applikationer" + }, "atRiskPasswords": { "message": "Lösenord i riskzonen" }, @@ -586,6 +607,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "E-post" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nej" }, + "noAuth": { + "message": "Vem som helst med länken" + }, + "anyOneWithPassword": { + "message": "Alla som har ett lösenord inställt av dig" + }, "location": { "message": "Plats" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Nästa betalning" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5422,7 +5455,7 @@ "message": "Arkiverat objekt återställt" }, "archivedItemsRestored": { - "message": "Archived items restored" + "message": "Arkiverade objekt återställda" }, "restoredItem": { "message": "Återställde objekt" @@ -5626,13 +5659,13 @@ "message": "Send skapades!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Kopiera och dela denna Send-länk. Den kan visas av personer som du har angivet nästa $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Kopiera och dela denna Send-länk. Denna Send kommer att vara tillgänglig för alla med länken för nästa $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5916,13 +5949,13 @@ } }, "centralizeDataOwnership": { - "message": "Centralize organization ownership" + "message": "Centralisera organisationens ägarskap" }, "centralizeDataOwnershipDesc": { - "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + "message": "Alla medlemsobjekt kommer att ägas och hanteras av organisationen. Administratörer och ägare är undantagna. " }, "centralizeDataOwnershipContentAnchor": { - "message": "Learn more about centralized ownership", + "message": "Läs mer om centraliserat ägarskap", "description": "This will be used as a hyperlink" }, "benefits": { @@ -5932,19 +5965,19 @@ "message": "Gain full visibility into credential health, including shared and unshared items." }, "centralizeDataOwnershipBenefit2": { - "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + "message": "Överför enkelt objekt under medlemmens offboarding och succession, vilket garanterar att det inte finns några åtkomstluckor." }, "centralizeDataOwnershipBenefit3": { - "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + "message": "Ge alla användare ett dedikerat \"Mina objekt\"-utrymme för att hantera sina egna inloggningar." }, "centralizeDataOwnershipWarningTitle": { - "message": "Prompt members to transfer their items" + "message": "Fråga medlemmar att överföra sina objekt" }, "centralizeDataOwnershipWarningDesc": { "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." }, "centralizeDataOwnershipWarningLink": { - "message": "Learn more about the transfer" + "message": "Läs mer om överföringen" }, "organizationDataOwnership": { "message": "Genomför äganderätt till organisationsdata" @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "En eller flera organisationspolicyer hindrar dig från att exportera ditt enskilda valv." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Aktivera autofyll" }, - "activateAutofillPolicyDesc": { - "message": "Aktivera inställningen för automatisk ifyllnad vid sidladdning i webbläsartillägget för alla befintliga och nya medlemmar." + "activateAutofillPolicyDescription": { + "message": "Aktivera autofyll på sidladdningsinställningar på webbläsartillägget för alla befintliga och nya medlemmar." }, - "experimentalFeature": { - "message": "Komprometterade eller opålitliga webbplatser kan utnyttja automatisk ifyllning vid sidladdning." + "autofillOnPageLoadExploitWarning": { + "message": "Komprometterade eller ej betrodda webbplatser kan utnyttja automatisk ifyllnad vid sidladdning." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Läs mer om autofyll" }, "selectType": { @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Skicka data om valvhändelser till din Datadog-instans" }, + "huntressEventIntegrationDesc": { + "message": "Skicka händelsedata till din Huntress SIEM-instans" + }, "failedToSaveIntegration": { "message": "Misslyckades med att spara integration. Försök igen senare." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Välj en plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden kommer att försöka göra anspråk på domänen 3 gånger under de första 72 timmarna. Om domänen inte kan göras anspråk på, kontrollera DNS-posten i din host och gör anspråk manuellt. Domänen kommer att tas bort från din organisation inom 7 dagar om den inte görs anspråk på." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden kommer att försöka göra anspråk på domänen inom 72 timmar. Om domänen inte kan hävdas, kontrollera din DNS-post och anspråk manuellt. Domäner som inte gjorts anspråk på tas bort efter 7 dagar." + }, + "automaticDomainClaimProcess2": { + "message": "När de har gjorts anspråk på kommer befintliga medlemmar med dessa domäner kommer att mailas om " + }, + "accountOwnershipChange": { + "message": "byte av kontoäganderätt" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ inte hävdad. Kontrollera dina DNS-poster.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Ägd" }, - "domainStatusUnderVerification": { - "message": "Under verifiering" + "domainStatusPending": { + "message": "Väntande" }, "claimedDomainsDescription": { "message": "Begär en domän för att äga medlemskonton. SSO-identifierarsidan kommer att hoppas över under inloggningen för medlemmar med namngivna domäner och administratörer kommer att kunna ta bort begärda konton." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verifiera nu." }, + "unlockWithPasskey": { + "message": "Lås upp med lösennyckel" + }, + "prfUnlockFailed": { + "message": "Det gick inte att låsa upp med lösennyckel. Försök igen eller använd en annan upplåsningsmetod." + }, + "noPrfCredentialsAvailable": { + "message": "Inga PRF-aktiverade lösennycklar finns tillgängliga för upplåsning." + }, "additionalStorageGB": { "message": "Ytterligare lagringsplats (GB)" }, @@ -12643,7 +12706,7 @@ "message": "Lagringen är full" }, "storageUsedDescription": { - "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "message": "Du har använt $USED$ av $AVAILABLE$ GB av din krypterade fillagring.", "placeholders": { "used": { "content": "$1", @@ -12656,15 +12719,33 @@ } }, "storageFullDescription": { - "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + "message": "Du har använt alla $GB$ GB av din krypterade lagring. För att fortsätta lagra filer, lägg till mer lagringsutrymme." + }, + "whoCanView": { + "message": "Vem kan se" + }, + "specificPeople": { + "message": "Specifika personer" + }, + "emailVerificationDesc": { + "message": "Efter att ha delat denna Send-länk kommer individer att behöva verifiera sin e-post med en kod för att visa denna Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Ange flera e-postadresser genom att separera dem med kommatecken." + }, + "emailPlaceholder": { + "message": "användare@bitwarden.com , användare@acme.com" }, "whenYouRemoveStorage": { - "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + "message": "När du tar bort lagring kommer du att få en proportionell kontokredit som automatiskt går mot din nästa faktura." }, "youHavePremium": { "message": "Du har Premium" }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Ogiltigt Send-lösenord" } } diff --git a/apps/web/src/locales/ta/messages.json b/apps/web/src/locales/ta/messages.json index 7902bb19e02..df578c8c2aa 100644 --- a/apps/web/src/locales/ta/messages.json +++ b/apps/web/src/locales/ta/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "முக்கியமான பயன்பாடுகளில் ஆபத்து ஏதும் இல்லை" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "அணுகல் நுண்ணறிவு" }, @@ -250,6 +268,9 @@ "application": { "message": "பயன்பாடு" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "ஆபத்தான கடவுச்சொற்கள்" }, @@ -586,6 +607,9 @@ "email": { "message": "மின்னஞ்சல்" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "தொலைபேசி" }, @@ -1365,6 +1389,12 @@ "no": { "message": "இல்லை" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "இடம்" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "ஒன்று அல்லது அதற்கு மேற்பட்ட நிறுவன கொள்கைகள் உங்கள் தனிப்பட்ட பெட்டகத்தை ஏற்றுமதி செய்வதைத் தடுக்கின்றன." }, - "activateAutofill": { - "message": "தானாக நிரப்புவதை செயல்படுத்து" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "ஏற்கனவே உள்ள மற்றும் புதிய அனைத்து உறுப்பினர்களுக்கும் உலாவி நீட்டிப்பில் பக்கம் ஏற்றப்படும்போது தானாக நிரப்பும் அமைப்பைச் செயல்படுத்து." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "சந்தேகத்திற்குரிய அல்லது நம்பத்தகாத வலைத்தளங்கள் பக்கம் ஏற்றப்படும்போது தானாக நிரப்புவதைப் பயன்படுத்திக் கொள்ளலாம்." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "தானாக நிரப்புதல் பற்றி மேலும் அறிக" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "SSO வகையைத் தேர்ந்தெடுக்கவும்" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "ஒருங்கிணைப்பைச் சேமிக்கத் தவறிவிட்டது. பின்னர் மீண்டும் முயற்சிக்கவும்." }, @@ -10543,6 +10579,12 @@ "index": { "message": "குறியீடு" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "ஒரு திட்டத்தைத் தேர்ந்தெடுக்கவும்" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "முதல் 72 மணிநேரத்திற்குள் 3 முறை டொமைனைக் கோர Bitwarden முயற்சிக்கும். டொமைனைக் கோர முடியவில்லையெனில், உங்கள் ஹோஸ்டில் உள்ள DNS பதிவைச் சரிபார்த்து, கைமுறையாகக் கோரவும். டொமைன் கோரப்படாவிட்டால் 7 நாட்களில் உங்கள் அமைப்பிலிருந்து அகற்றப்படும்." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ கோரப்படவில்லை. உங்கள் DNS பதிவுகளைச் சரிபார்க்கவும்.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "கோரப்பட்டது" }, - "domainStatusUnderVerification": { - "message": "சரிபார்ப்பில் உள்ளது" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "உறுப்பினர் கணக்குகளைச் சொந்தமாக்க ஒரு டொமைனைக் கோரவும். கோரப்பட்ட டொமைன்கள் கொண்ட உறுப்பினர்களுக்காக உள்நுழையும் போது SSO அடையாளங்காட்டி பக்கம் தவிர்க்கப்படும், மேலும் நிர்வாகிகள் கோரப்பட்ட கணக்குகளை நீக்க முடியும்." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index 53229a365bb..47af931229d 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index c8fcb955fd8..aa3d317b324 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "อีเมล" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "โทรศัพท์" }, @@ -1365,6 +1389,12 @@ "no": { "message": "ไม่ใช่" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index 52dae5e86ac..f38800d72f7 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Risk altında olan kritik uygulama yok" }, + "critical": { + "message": "Kritik ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Kritik değil ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Uygulama" }, + "applications": { + "message": "Uygulamalar" + }, "atRiskPasswords": { "message": "Riskli parolalar" }, @@ -586,6 +607,9 @@ "email": { "message": "E-posta" }, + "emails": { + "message": "E-postalar" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Hayır" }, + "noAuth": { + "message": "Bağlantıya sahip olan herkes" + }, + "anyOneWithPassword": { + "message": "Belirlediğiniz parolaya sahip olan herkes" + }, "location": { "message": "Konum" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Sonraki ödeme" }, + "nextChargeDate": { + "message": "Sonraki ödeme tarihi" + }, "plan": { "message": "Paket" }, @@ -5626,13 +5659,13 @@ "message": "Send başarıyla oluşturuldu.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Bu Send bağlantısını kopyalayıp paylaşın. Belirlediğiniz kişiler bağlantıyı önümüzdeki $TIME$ boyunca kullanabilir.", + "sendCreatedDescriptionV2": { + "message": "Bu Send bağlantısını kopyalayıp paylaşın. Bu Send'e önümüzdeki $TIME$ boyunca bağlantıya sahip herkes ulaşabilecektir.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Bir veya daha fazla kuruluş ilkesi, kişisel kasanızı dışa aktarmanızı engelliyor." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Otomatik doldurmayı etkinleştir" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Tüm mevcut ve yeni üyeler için tarayıcı uzantısındaki \"sayfa yüklendiğinde otomatik doldur\" ayarını etkinleştir." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Ele geçirilmiş veya güvenilmeyen web siteleri sayfa yüklenirken otomatik doldurmayı suistimal edebilir." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Otomatik doldurma hakkında bilgi alın" }, "selectType": { @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Kasa olay verilerini Datadog örneğinize gönderin" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Entegrasyon kaydedilemedi. Lütfen daha sonra tekrar deneyin." }, @@ -10543,6 +10579,12 @@ "index": { "message": "İndeks" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Bir plan seçin" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden, ilk 72 saat içinde etki alanını 3 kez talep etmeye çalışacaktır. Etki alanı talep edilemezse, barındırıcınızdaki DNS kaydını kontrol edin ve manuel olarak talep edin. Etki alanı talep edilmezse, 7 gün içinde kuruluşunuzdan kaldırılacaktır." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ alınmadı. DNS kayıtlarınızı kontrol edin.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Alındı" }, - "domainStatusUnderVerification": { - "message": "Doğrulama altında" + "domainStatusPending": { + "message": "Beklemede" }, "claimedDomainsDescription": { "message": "Üye hesaplarını sahip olmak için bir alan adı talep edin. Alan adı talep eden üyelerin oturum açma sırasında SSO tanımlayıcı sayfası atlanacak ve yöneticiler talep edilen hesapları silebilecek." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Şimdi doğrulayın." }, + "unlockWithPasskey": { + "message": "Kilidi geçiş anahtarıyla aç" + }, + "prfUnlockFailed": { + "message": "Kilit geçiş anahtarıyla açılamadı. Lütfen yeniden deneyin veya başka bir kilit açma yöntemi kullanın." + }, + "noPrfCredentialsAvailable": { + "message": "Kilit açma için PRF uyumlu geçiş anahtarı bulunamadı." + }, "additionalStorageGB": { "message": "Ek depolama alanı GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Kim görebilir" + }, + "specificPeople": { + "message": "Belirli kişiler" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "E-posta adreslerini virgülle ayırarak yazın." + }, + "emailPlaceholder": { + "message": "kullanici@bitwarden.com , kullanici@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Geçersiz Send parolası" } } diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index 2f170f54750..bfca185fa57 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -3,7 +3,7 @@ "message": "Всі програми" }, "activity": { - "message": "Activity" + "message": "Активність" }, "appLogoLabel": { "message": "Логотип Bitwarden" @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Немає критичних програм із ризиком" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Управління доступом" }, @@ -134,7 +152,7 @@ "message": "critical applications marked" }, "countOfCriticalApplications": { - "message": "$COUNT$ critical applications", + "message": "$COUNT$ критичних програм", "placeholders": { "count": { "content": "$1", @@ -179,7 +197,7 @@ } }, "noDataInOrgTitle": { - "message": "No data found" + "message": "Дані не знайдено" }, "noDataInOrgDescription": { "message": "Import your organization's login data to get started with Access Intelligence. Once you do that, you'll be able to:" @@ -209,13 +227,13 @@ "message": "You’re ready to start generating reports. Once you generate, you’ll be able to:" }, "noCriticalApplicationsTitle": { - "message": "Ви не відмітили жодного додатку в якості критичного" + "message": "Ви не позначили жодної програми в якості критичної" }, "noCriticalApplicationsDescription": { "message": "Select your most critical applications to prioritize security actions for your users to address at-risk passwords." }, "markCriticalApplications": { - "message": "Вибрати критичні додатки" + "message": "Вибрати критичні програми" }, "markAppAsCritical": { "message": "Позначити програму критичною" @@ -224,13 +242,13 @@ "message": "Mark as critical" }, "applicationsSelected": { - "message": "applications selected" + "message": "програм обрано" }, "selectApplication": { - "message": "Select application" + "message": "Обрати програму" }, "unselectApplication": { - "message": "Unselect application" + "message": "Скасувати вибір програми" }, "applicationsMarkedAsCriticalSuccess": { "message": "Позначені критичні програми" @@ -250,6 +268,9 @@ "application": { "message": "Програма" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Ризиковані паролі" }, @@ -344,10 +365,10 @@ "message": "Applications needing review" }, "newApplicationsCardTitle": { - "message": "Review new applications" + "message": "Перегляд нових програм" }, "newApplicationsWithCount": { - "message": "$COUNT$ new applications", + "message": "$COUNT$ нових програм", "placeholders": { "count": { "content": "$1", @@ -380,7 +401,7 @@ "message": "Review applications to secure the items most critical to your organization's security" }, "reviewApplications": { - "message": "Review applications" + "message": "Перегляд програм" }, "prioritizeCriticalApplications": { "message": "Prioritize critical applications" @@ -404,7 +425,7 @@ "message": "Application review saved" }, "newApplicationsReviewed": { - "message": "New applications reviewed" + "message": "Переглянуто нові програми" }, "errorSavingReviewStatus": { "message": "Error saving review status" @@ -586,6 +607,9 @@ "email": { "message": "Е-пошта" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ні" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Розташування" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5626,13 +5659,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Одна чи декілька політик організації не дозволяють вам експортувати особисте сховище." }, - "activateAutofill": { - "message": "Активувати автозаповнення" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Активувати автозаповнення під час завантаження сторінки в налаштуваннях розширення браузера для всіх наявних і нових учасників." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Скомпрометовані або ненадійні вебсайти можуть використати функцію автозаповнення під час завантаження сторінки для завдання шкоди." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Дізнатися більше про автозаповнення" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Оберіть тип SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Оберіть тарифний план" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden намагатиметься заявити домен 3 рази впродовж 72 годин. Якщо не вдасться заявити домен, перевірте DNS-запис у вашого провайдера й заявіть його вручну. Якщо домен не буде заявлено протягом 7 днів, його буде вилучено з вашої організації." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ не заявлено. Перевірте свої DNS-записи.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Заявлено" }, - "domainStatusUnderVerification": { - "message": "Проходить перевірку" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12379,7 +12442,7 @@ "message": "If you don't verify your organization, your access to the organization will be revoked." }, "leaveNow": { - "message": "Leave now" + "message": "Покинути зараз" }, "verifyYourDomainToLogin": { "message": "Verify your domain to log in" @@ -12489,7 +12552,7 @@ "message": "Set an unlock method to change your timeout action" }, "leaveConfirmationDialogTitle": { - "message": "Are you sure you want to leave?" + "message": "Ви дійсно хочете покинути?" }, "leaveConfirmationDialogContentOne": { "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features." @@ -12498,7 +12561,7 @@ "message": "Contact your admin to regain access." }, "leaveConfirmationDialogConfirmButton": { - "message": "Leave $ORGANIZATION$", + "message": "Покинути $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -12531,7 +12594,7 @@ "message": "Accept transfer" }, "declineAndLeave": { - "message": "Decline and leave" + "message": "Відхилити та покинути" }, "whyAmISeeingThis": { "message": "Why am I seeing this?" @@ -12579,7 +12642,7 @@ "message": "Open the subscription page on your Bitwarden cloud account and download your license file. Then return to this screen and upload it below." }, "viewAllPlans": { - "message": "View all plans" + "message": "Переглянути всі тарифні плани" }, "planDescPremium": { "message": "Complete online security" @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index d7d3706f3ec..142fb08f6ac 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Không có ứng dụng quan trọng nào bị đe dọa" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Trí tuệ truy cập" }, @@ -250,6 +268,9 @@ "application": { "message": "Ứng dụng" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Mật khẩu có rủi ro cao" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Điện thoại" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Không" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Vị trí" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Lần thanh toán tiếp theo" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Gói" }, @@ -5626,13 +5659,13 @@ "message": "Đã tạo Send thành công!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Các chính sách của tổ chức ngăn cản bạn xuất kho lưu trữ cá nhân của mình." }, - "activateAutofill": { - "message": "Bật tính năng tự động điền" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Bật tính năng tự động điền khi tải trang trên tiện ích mở rộng trình duyệt cho tất cả thành viên hiện tại và mới." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Các trang web bị xâm phạm hoặc không đáng tin cậy có thể khai thác tính năng tự động điền khi tải trang." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Tìm hiểu thêm về tự động điền" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Chọn loại SSO" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "Gửi dữ liệu sự kiện kho bảo mật đến phiên bản Datadog của bạn" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Không thể lưu tích hợp. Vui lòng thử lại sau." }, @@ -10543,6 +10579,12 @@ "index": { "message": "Mục lục" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Chọn một gói" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden sẽ thử xác nhận tên miền 3 lần trong 72 giờ đầu tiên. Nếu tên miền không thể được xác nhận, hãy kiểm tra bản ghi DNS trong máy chủ của bạn và tự xác nhận. Tên miền sẽ bị xóa khỏi tổ chức của bạn trong 7 ngày nếu không được xác nhận." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ chưa được xác nhận. Vui lòng kiểm tra bản ghi DNS của bạn.", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "Đã xác nhận" }, - "domainStatusUnderVerification": { - "message": "Đang trong quá trình xác minh" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Xác nhận một tên miền để sở hữu tài khoản của thành viên. Trang định danh SSO sẽ bị bỏ qua trong quá trình đăng nhập cho các thành viên có tên miền đã xác nhận và quản trị viên sẽ có thể xóa các tài khoản đã xác nhận." @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "Xác minh ngay." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "GB lưu trữ bổ sung" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 783d0d5bef7..e1f52f5fdfe 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "没有关键应用程序存在风险" }, + "critical": { + "message": "关键 ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "非关键 ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "应用程序" }, + "applications": { + "message": "应用程序" + }, "atRiskPasswords": { "message": "存在风险的密码" }, @@ -586,6 +607,9 @@ "email": { "message": "电子邮箱" }, + "emails": { + "message": "电子邮箱" + }, "phone": { "message": "电话" }, @@ -796,7 +820,7 @@ } }, "passwordSafe": { - "message": "没有在已知的数据泄露中发现此密码,它暂时比较安全。" + "message": "在任何已知的数据泄露中均未发现此密码。它暂时比较安全。" }, "save": { "message": "保存" @@ -1070,7 +1094,7 @@ "message": "其他" }, "share": { - "message": "分享" + "message": "共享" }, "moveToOrganization": { "message": "移动到组织" @@ -1365,6 +1389,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "拥有此链接的任何人" + }, + "anyOneWithPassword": { + "message": "拥有您设置的密码的任何人" + }, "location": { "message": "位置" }, @@ -1375,7 +1405,7 @@ "message": "使用设备登录" }, "loginWithDeviceEnabledNote": { - "message": "必须在 Bitwarden App 的设置中启用设备登录。需要其他选项吗?" + "message": "必须在 Bitwarden App 的设置中设置设备登录。需要其他选项吗?" }, "needAnotherOptionV1": { "message": "需要其他选项吗?" @@ -1829,7 +1859,7 @@ "message": "登录不可用" }, "noTwoStepProviders": { - "message": "此账户已启用两步登录,但此浏览器不支持任何已配置的两步登录提供程序。" + "message": "此账户已设置两步登录,但此浏览器不支持任何已配置的两步登录提供程序。" }, "noTwoStepProviders2": { "message": "请使用受支持的网页浏览器(例如 Chrome),和/或添加其他跨网页浏览器支持更好的提供程序(例如验证器 App)。" @@ -2140,7 +2170,7 @@ } }, "loggedOutWarning": { - "message": "继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "changePasswordWarning": { "message": "更改密码后,您需要使用新密码登录。在其他设备上的活动会话将在一小时内注销。" @@ -2225,7 +2255,7 @@ "message": "您是否担心自己的账户在其他设备上登录过?继续下面的操作以取消对之前使用过的所有计算机或设备的授权。如果您以前使用过公共计算机或不小心曾将密码保存在不属于您的设备上,则建议执行此安全步骤。此步骤还将清除所有以前记住的两步登录会话。" }, "deauthorizeSessionsWarning": { - "message": "继续操作还将使您退出当前会话,并要求您重新登录。如果有设置两步登录,也需要重新验证。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "继续操作还将使您注销当前会话,并要求您重新登录。如果有设置两步登录,也需要重新验证。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "newDeviceLoginProtection": { "message": "新设备登录" @@ -2671,7 +2701,7 @@ "message": "保存表单。" }, "twoFactorYubikeyWarning": { - "message": "由于平台限制,YubiKey 不能在所有 Bitwarden 应用程序上使用。您应该启用另一个两步登录提供程序,以便在无法使用 YubiKey 时可以访问您的账户。支持的平台:" + "message": "由于平台限制,YubiKey 不能在所有 Bitwarden 应用程序上使用。您应该设置其他两步登录提供程序,以便在无法使用 YubiKey 时可以访问您的账户。支持的平台:" }, "twoFactorYubikeySupportUsb": { "message": "具有可使用 YubiKey 的 USB 端口的设备上的网页密码库、桌面应用程序、CLI 以及浏览器扩展。" @@ -2689,7 +2719,7 @@ } }, "u2fkeyX": { - "message": "U2F Key $INDEX$", + "message": "U2F 密钥 $INDEX$", "placeholders": { "index": { "content": "$1", @@ -2710,7 +2740,7 @@ "message": "NFC 支持" }, "twoFactorYubikeySupportsNfc": { - "message": "我的一把密钥支持 NFC。" + "message": "我的某个密钥支持 NFC。" }, "twoFactorYubikeySupportsNfcDesc": { "message": "如果您的某个 YubiKey 支持 NFC(例如 YubiKey NEO),移动设备在检测到 NFC 可用时将提示您。" @@ -2719,7 +2749,7 @@ "message": "YubiKey 已更新" }, "disableAllKeys": { - "message": "禁用全部密钥" + "message": "停用全部密钥" }, "twoFactorDuoDesc": { "message": "输入 Duo 管理面板提供的 Bitwarden 应用程序信息。" @@ -2773,7 +2803,7 @@ "message": "保存表单。" }, "twoFactorU2fWarning": { - "message": "由于平台限制,FIDO U2F 不能在所有 Bitwarden 应用程序上使用。您应该启用另一个两步登录提供程序,以便在无法使用 FIDO U2F 时可以访问您的账户。支持的平台:" + "message": "由于平台限制,FIDO U2F 不能在所有 Bitwarden 应用程序上使用。您应该设置其他两步登录提供程序,以便在无法使用 FIDO U2F 时可以访问您的账户。支持的平台:" }, "twoFactorU2fSupportWeb": { "message": "桌面/笔记本电脑上支持 U2F 的浏览器(启用了 FIDO U2F 的 Chrome、Opera、Vivaldi 或 Firefox)中的网页密码库和浏览器扩展。" @@ -2782,7 +2812,7 @@ "message": "等待您触摸安全密钥上的按钮" }, "twoFactorU2fClickSave": { - "message": "单击下面的「保存」按钮,以启用此安全密钥用于两步登录。" + "message": "单击下面的「保存」按钮,以激活此安全密钥用于两步登录。" }, "twoFactorU2fProblemReadingTryAgain": { "message": "读取安全密钥时出现问题。请重试。" @@ -2791,7 +2821,7 @@ "message": "您的 Bitwarden 两步登录恢复代码" }, "twoFactorRecoveryNoCode": { - "message": "您尚未设置任何两步登录提供程序。在启用了一个两步登录提供程序后,请返回这里检查恢复代码。" + "message": "您尚未设置任何两步登录提供程序。设置两步登录提供程序后,返回这里查看您的恢复代码。" }, "printCode": { "message": "打印代码", @@ -2837,7 +2867,7 @@ "message": "未激活两步登录" }, "inactive2faReportDesc": { - "message": "两步登录为您的账户增加了一层保护。使用 Bitwarden Authenticator 或其他方式为这些账户开启两步登录。" + "message": "两步登录为您的账户增加了一层保护。使用 Bitwarden Authenticator 或其他方式为这些账户设置两步登录。" }, "inactive2faFound": { "message": "发现未启用两步登录的登录项目" @@ -2979,7 +3009,7 @@ "message": "检查泄漏情况" }, "breachUsernameNotFound": { - "message": "没有在已知的数据泄露中发现 $USERNAME$。", + "message": "在任何已知的数据泄露中均未发现 $USERNAME$。", "placeholders": { "username": { "content": "$1", @@ -2992,7 +3022,7 @@ "description": "ex. Good News, No Breached Accounts Found!" }, "breachUsernameFound": { - "message": "$USERNAME$ 在不同的在线数据泄漏中找到 $COUNT$ 次。", + "message": "在 $COUNT$ 个不同的在线数据泄露中发现了 $USERNAME$。", "placeholders": { "username": { "content": "$1", @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "下一次收费" }, + "nextChargeDate": { + "message": "下一次收费日期" + }, "plan": { "message": "方案" }, @@ -3491,7 +3524,7 @@ "description": "Free as in 'free beer'." }, "planDescFree": { - "message": "适用于测试或与 $COUNT$ 位其他用户共享的个人用户。", + "message": "适用于测试或个人用户与 $COUNT$ 位其他用户共享。", "placeholders": { "count": { "content": "$1", @@ -5626,13 +5659,13 @@ "message": "Send 创建成功!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "复制并分享此 Send 链接。您指定的人员可在接下来的 $TIME$ 内查看此 Send。", + "sendCreatedDescriptionV2": { + "message": "复制并分享此 Send 链接。在接下来的 $TIME$ 内,拥有此链接的任何人都可以访问此 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6448,7 +6481,7 @@ "message": "重置密码" }, "resetPasswordLoggedOutWarning": { - "message": "继续操作会将 $NAME$ 登出当前会话,并要求他们重新登录。在其他设备上的活动会话可能继续活动长达一个小时。", + "message": "继续操作将使 $NAME$ 注销当前会话,并要求他们重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。", "placeholders": { "name": { "content": "$1", @@ -6457,7 +6490,7 @@ } }, "emergencyAccessLoggedOutWarning": { - "message": "继续操作会将 $NAME$ 登出当前会话,并要求他们重新登录。在其他设备上的活动会话可能继续活动长达一个小时。", + "message": "继续操作将使 $NAME$ 注销当前会话,并要求他们重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。", "placeholders": { "name": { "content": "$1", @@ -6631,7 +6664,7 @@ "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { - "message": "以避免额外的数据丢失。", + "message": "以避免进一步的数据丢失。", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "accountRecoveryManageUsers": { @@ -6781,13 +6814,13 @@ "message": "您的主密码不符合本组织的要求。更改您的主密码以继续。" }, "updateMasterPasswordWarning": { - "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "masterPasswordInvalidWarning": { - "message": "您的主密码不符合此组织的策略要求。要加入此组织,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合此组织的策略要求。要加入此组织,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "updateWeakMasterPasswordWarning": { - "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "automaticAppLoginWithSSO": { "message": "使用 SSO 自动登录" @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "一个或多个组织策略阻止您导出个人密码库。" }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "激活自动填充" }, - "activateAutofillPolicyDesc": { - "message": "为所有现有成员和新成员激活浏览器扩展上的页面加载时的自动填充设置。" + "activateAutofillPolicyDescription": { + "message": "为所有现有成员和新成员激活浏览器扩展上的页面加载时自动填充设置。" }, - "experimentalFeature": { - "message": "被入侵或不受信任的网站可能恶意利用页面加载时的自动填充功能。" + "autofillOnPageLoadExploitWarning": { + "message": "被攻破或不受信任的网站可能会利用页面加载时的自动填充功能。" }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "进一步了解自动填充" }, "selectType": { @@ -7286,7 +7319,7 @@ "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Connect login with SSO to your self-hosted decryption key server. Using this option, members won’t need to use their master passwords to decrypt vault data. The require SSO authentication and single organization policies are required to set up Key Connector decryption. Contact Bitwarden Support for set up assistance.'" }, "keyConnectorPolicyRestriction": { - "message": "「SSO 登录和 Key Connector 解密」已启用。此策略仅适用于所有者和管理员。" + "message": "「SSO 登录和 Key Connector 解密」已激活。此策略仅适用于所有者和管理员。" }, "enabledSso": { "message": "SSO 已启用" @@ -7304,7 +7337,7 @@ } }, "enabledKeyConnector": { - "message": "Key Connector 已启用" + "message": "Key Connector 已激活" }, "disabledKeyConnector": { "message": "Key Connector 已停用" @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "将密码库事件数据发送到您的 Datadog 实例" }, + "huntressEventIntegrationDesc": { + "message": "将事件数据发送到您的 Huntress SIEM 实例" + }, "failedToSaveIntegration": { "message": "保存集成失败。请稍后再试。" }, @@ -10543,6 +10579,12 @@ "index": { "message": "索引" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "选择一个方案" }, @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden 将在最初的 72 小时内尝试声明域名 3 次。如果此域名无法声明,请检查您主机中的 DNS 记录并手动声明。如果此域名在 7 天内未声明,它将被从您的组织中移除。" }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden 将在 72 小时内尝试声明此域名。如果此域名无法声明,请验证您的 DNS 记录并手动声明。未声明的域名将在 7 天后被移除。" + }, + "automaticDomainClaimProcess2": { + "message": "声明后,使用已声明域名的现有成员将收到关于" + }, + "accountOwnershipChange": { + "message": "账户所有权变更" + }, + "automaticDomainClaimProcessEnd": { + "message": "的电子邮件通知。" + }, "domainNotClaimed": { "message": "$DOMAIN$ 无法声明。请检查您的 DNS 记录。", "placeholders": { @@ -11369,11 +11423,11 @@ "domainStatusClaimed": { "message": "已声明" }, - "domainStatusUnderVerification": { - "message": "验证中" + "domainStatusPending": { + "message": "处理中" }, "claimedDomainsDescription": { - "message": "声明域名以拥有成员账户。已声明域名的成员登录时将跳过 SSO 标识符页面,管理员也可以删除已声明的账户。" + "message": "声明域名以拥有成员账户。使用已声明域名的成员登录时将跳过 SSO 标识符页面,管理员也可以删除已声明的账户。" }, "invalidDomainNameClaimMessage": { "message": "输入的格式无效。格式:mydomain.com。子域名需要单独的条目进行声明。" @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "立即验证。" }, + "unlockWithPasskey": { + "message": "使用通行密钥解锁" + }, + "prfUnlockFailed": { + "message": "使用通行密钥解锁失败。请重试或使用其他解锁方式。" + }, + "noPrfCredentialsAvailable": { + "message": "没有可用于解锁的 PRF 通行密钥。" + }, "additionalStorageGB": { "message": "附加存储 GB" }, @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "您已使用了全部的 $GB$ GB 加密存储空间。要继续存储文件,请添加更多存储空间。" }, + "whoCanView": { + "message": "谁可以查看" + }, + "specificPeople": { + "message": "指定的人员" + }, + "emailVerificationDesc": { + "message": "分享此 Send 链接后,个人需要使用验证码验证他们的电子邮箱才能查看此 Send。" + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "输入多个电子邮箱(使用英文逗号分隔)。" + }, + "emailPlaceholder": { + "message": "user@bitwarden.com, user@acme.com" + }, "whenYouRemoveStorage": { "message": "当您移除存储空间时,您将收到一笔按比例计算的账户信用额度,其将用于自动抵扣您的下一笔费用。" }, @@ -12665,6 +12743,9 @@ "message": "您拥有高级版" }, "emailProtected": { - "message": "Email protected" + "message": "电子邮箱保护" + }, + "invalidSendPassword": { + "message": "无效的 Send 密码" } } diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index 598a712e8c7..90e4f214678 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -6,16 +6,34 @@ "message": "活動" }, "appLogoLabel": { - "message": "Bitwarden 圖示" + "message": "Bitwarden logo" }, "criticalApplications": { - "message": "重要應用程式" + "message": "關鍵應用程式" }, "noCriticalAppsAtRisk": { - "message": "沒有關鍵應用程式處於風險中" + "message": "目前沒有關鍵應用程式存在風險" + }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } }, "accessIntelligence": { - "message": "存取資訊" + "message": "Access Intelligence" }, "passwordRisk": { "message": "密碼風險" @@ -24,13 +42,13 @@ "message": "你沒有權限編輯這個項目" }, "reviewAtRiskPasswords": { - "message": "檢視全部應用中具有風險的密碼 (弱、被暴露或重複使用)。選擇最重要的應用程式並優先採取安全措施,幫助使用者解決具有風險的密碼。" + "message": "檢視各應用程式中的風險密碼(弱、外洩或重複使用)。選擇最關鍵的應用程式,優先採取安全措施,協助使用者處理這些密碼。" }, "reviewAtRiskLoginsPrompt": { "message": "檢視有風險的登入資訊" }, "dataLastUpdated": { - "message": "上次資料更新日期:$DATE$", + "message": "資料最後更新於:$DATE$", "placeholders": { "date": { "content": "$1", @@ -42,7 +60,7 @@ "message": "您尚未建立報告" }, "notifiedMembers": { - "message": "已被通知的成員" + "message": "已通知成員" }, "revokeMembers": { "message": "撤銷成員" @@ -63,7 +81,7 @@ } }, "createNewLoginItem": { - "message": "新增登入項目" + "message": "建立新的登入項目" }, "percentageCompleted": { "message": "完成 $PERCENT$%", @@ -91,7 +109,7 @@ "message": "密碼變更進度" }, "assignMembersTasksToMonitorProgress": { - "message": "指派成員任務以監控進度" + "message": "指派任務給成員以監控進度" }, "onceYouReviewApplications": { "message": "當您審查應用程式並將其標記為關鍵後,可指派任務給成員以變更其密碼。" @@ -122,7 +140,7 @@ } }, "criticalApplicationsWithCount": { - "message": "重要應用程式($COUNT$)", + "message": "關鍵應用程式 ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -131,7 +149,7 @@ } }, "criticalApplicationsMarked": { - "message": "已將應用程式標記為關鍵" + "message": "已標記為關鍵應用程式" }, "countOfCriticalApplications": { "message": "$COUNT$ 個關鍵應用程式", @@ -170,7 +188,7 @@ } }, "notifiedMembersWithCount": { - "message": "已被通知的成員($COUNT$)", + "message": "已通知成員 ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -182,13 +200,13 @@ "message": "找不到資料" }, "noDataInOrgDescription": { - "message": "匯入您組織的登入資料以開始使用存取智慧功能。完成後,您將能夠:" + "message": "匯入組織的登入資料即可開始使用 Access Intelligence。完成後,您將能夠:" }, "feature1Title": { "message": "將應用程式標記為關鍵" }, "feature1Description": { - "message": "這將協助您優先消除最重要應用程式的風險。" + "message": "這將協助您優先消除關鍵應用程式的風險。" }, "feature2Title": { "message": "協助成員提升其安全性" @@ -197,7 +215,7 @@ "message": "指派有風險的成員執行指導式安全任務以更新憑證。" }, "feature3Title": { - "message": "監控進展" + "message": "追蹤進度" }, "feature3Description": { "message": "追蹤隨時間變化的狀況以顯示安全性改善。" @@ -215,13 +233,13 @@ "message": "選擇您最關鍵的應用程式,以優先處理安全行動,讓使用者解決有風險的密碼。" }, "markCriticalApplications": { - "message": "選擇重要應用程式" + "message": "選擇關鍵應用程式" }, "markAppAsCritical": { - "message": "標註應用程式為重要" + "message": "將應用程式標記為關鍵" }, "markAsCritical": { - "message": "標註應用程式為重要" + "message": "標記為關鍵" }, "applicationsSelected": { "message": "已選擇的應用程式" @@ -233,7 +251,7 @@ "message": "取消選擇應用程式" }, "applicationsMarkedAsCriticalSuccess": { - "message": "被標註重要的應用程式" + "message": "已標記為關鍵的應用程式" }, "criticalApplicationsMarkedSuccess": { "message": "已將 $COUNT$ 個應用程式標記為關鍵", @@ -250,6 +268,9 @@ "application": { "message": "應用程式" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "具有風險的密碼" }, @@ -302,7 +323,7 @@ } }, "atRiskMemberDescription": { - "message": "這些成員正以薄弱、已外洩或重複使用的密碼登入關鍵應用程式。" + "message": "這些成員正以強度不足、已外洩或重複使用的密碼登入關鍵應用程式。" }, "atRiskMembersDescriptionNone": { "message": "目前沒有成員使用弱密碼、外洩密碼或重複密碼登入應用程式。" @@ -377,7 +398,7 @@ } }, "reviewApplicationsToSecureItems": { - "message": "審查應用程式以保護對組織安全最重要的項目" + "message": "檢視應用程式,以確保對組織安全最關鍵的項目受到保護" }, "reviewApplications": { "message": "審核認領" @@ -477,7 +498,7 @@ "message": "身分" }, "contactInfo": { - "message": "聯繫資訊" + "message": "聯絡資訊" }, "cardDetails": { "message": "支付卡詳細資料" @@ -514,16 +535,16 @@ } }, "websiteAdded": { - "message": "網站已添加" + "message": "網站已新增" }, "addWebsite": { - "message": "添加網站" + "message": "新增網站" }, "deleteWebsite": { "message": "刪除網站" }, "defaultLabel": { - "message": "預設 ($VALUE$)", + "message": "預設($VALUE$)", "description": "A label that indicates the default value for a field with the current default value in parentheses.", "placeholders": { "value": { @@ -533,7 +554,7 @@ } }, "showMatchDetection": { - "message": "顯示偵測到的吻合 $WEBSITE$", + "message": "顯示偵測到相符的 $WEBSITE$", "placeholders": { "website": { "content": "$1", @@ -542,7 +563,7 @@ } }, "hideMatchDetection": { - "message": "隱藏偵測到的吻合 $WEBSITE$", + "message": "隱藏偵測到相符的 $WEBSITE$", "placeholders": { "website": { "content": "$1", @@ -560,7 +581,7 @@ "message": "發卡組織" }, "expiration": { - "message": "逾期" + "message": "到期日" }, "securityCode": { "message": "安全碼 (CVV)" @@ -575,17 +596,20 @@ "message": "公司" }, "ssn": { - "message": "社會保險號碼" + "message": "社會安全號碼" }, "passportNumber": { "message": "護照號碼" }, "licenseNumber": { - "message": "許可證號碼" + "message": "駕照號碼" }, "email": { "message": "電子郵件" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "電話號碼" }, @@ -650,10 +674,10 @@ "message": "如果您已續卡,請更新支付卡資訊" }, "expirationMonth": { - "message": "逾期月份" + "message": "到期月份" }, "expirationYear": { - "message": "逾期年份" + "message": "到期年份" }, "authenticatorKeyTotp": { "message": "驗證器金鑰 (TOTP)" @@ -668,7 +692,7 @@ "message": "Bitwarden 可以儲存並填入兩步驟驗證碼。選擇相機圖示來截取此網站的驗證器QR code,或手動複製金鑰並貼上到此欄位。" }, "learnMoreAboutAuthenticators": { - "message": "了解更多驗證程式" + "message": "瞭解更多關於驗證器的資訊" }, "folder": { "message": "資料夾" @@ -705,7 +729,7 @@ "message": "未指派" }, "noneFolder": { - "message": "預設資料夾", + "message": "無資料夾", "description": "This is the folder for uncategorized items" }, "selfOwnershipLabel": { @@ -766,11 +790,11 @@ "description": "A programming term, also known as 'RegEx'." }, "matchDetection": { - "message": "一致性偵測", + "message": "相符偵測", "description": "URI match detection for auto-fill." }, "defaultMatchDetection": { - "message": "預設一致性偵測", + "message": "預設相符偵測", "description": "Default URI match detection for auto-fill." }, "never": { @@ -1153,7 +1177,7 @@ "message": "複製護照號碼" }, "copyLicenseNumber": { - "message": "複製許可證號碼" + "message": "複製駕照號碼" }, "copyPrivateKey": { "message": "複製私密金鑰" @@ -1365,6 +1389,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "位置" }, @@ -1384,7 +1414,7 @@ "message": "使用主密碼登入" }, "readingPasskeyLoading": { - "message": "正在讀取通行金鑰..." + "message": "正在讀取密碼金鑰…" }, "readingPasskeyLoadingInfo": { "message": "保持此視窗打開,然後按照瀏覽器的提示進行操作。" @@ -1656,7 +1686,7 @@ "message": "發生了未預期的錯誤。" }, "expirationDateError": { - "message": "請選擇一個未來的逾期日期。" + "message": "請選擇一個未來的到期日。" }, "emailAddress": { "message": "電子郵件地址" @@ -1925,7 +1955,7 @@ } }, "deleteSelectedConfirmation": { - "message": "您確定要繼續嗎?" + "message": "您確定要繼續嗎?" }, "moveSelectedItemsDesc": { "message": "選擇要將這 $COUNT$ 個項目移動至哪個資料夾。", @@ -2791,7 +2821,7 @@ "message": "您的 Bitwarden 兩步驟登入復原碼" }, "twoFactorRecoveryNoCode": { - "message": "您尚未啟用任何兩步驟登入方式。等你啟用兩步驟登入方式後,您可回來這裡取得復原碼。" + "message": "您目前尚未啟用任何兩步驟登入方式。啟用後,即可回到此處取得復原碼。" }, "printCode": { "message": "列印代碼", @@ -3153,7 +3183,7 @@ "message": "若要重新存取您的封存項目,請重新啟用進階版訂閱。若您在重新啟用前編輯封存項目的詳細資料,它將會被移回您的密碼庫。" }, "itemRestored": { - "message": "已還原項目" + "message": "項目已還原" }, "restartPremium": { "message": "重新啟用進階版" @@ -3165,7 +3195,7 @@ "message": "# GB 額外儲存空間" }, "additionalStorageIntervalDesc": { - "message": "您的方案擁有 $SIZE$ 的加密儲存空間。您也可以用每 GB $PRICE$ / $INTERVAL$ 購買額外的儲存空間。", + "message": "您的方案提供 $SIZE$ 的加密儲存空間。如需額外儲存空間,可依每 GB 每 $INTERVAL$ $PRICE$ 加購。", "placeholders": { "size": { "content": "$1", @@ -3252,19 +3282,19 @@ "message": "待取消" }, "subscriptionPendingCanceled": { - "message": "此訂閱在目前計費周期结束前已標記為取消。" + "message": "此訂閱已標記為將於目前計費週期結束時取消。" }, "reinstateSubscription": { "message": "恢復訂閱" }, "reinstateConfirmation": { - "message": "您是否要移除待處理的取消要求,重新開始您的訂閱?" + "message": "確定要撤回取消申請並恢復訂閱嗎?" }, "reinstated": { "message": "已重新開始訂閱。" }, "cancelConfirmation": { - "message": "您確定要取消訂閱嗎?在目前計費周期結束之後,您將無法使用所有訂閲功能。" + "message": "您確定要取消訂閱嗎?在本次計費週期結束後,您將無法再使用此訂閱的所有功能。" }, "canceledSubscription": { "message": "訂閱已取消" @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "下一次收費" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "方案" }, @@ -3845,7 +3878,7 @@ "message": "全部" }, "addAccess": { - "message": "添加存取權限" + "message": "新增存取權限" }, "addAccessFilter": { "message": "新增存取過濾器" @@ -4504,7 +4537,7 @@ "message": "更新瀏覽器" }, "generatingYourAccessIntelligence": { - "message": "正在產生您的存取智慧分析…" + "message": "正在產生您的 Access Intelligence……" }, "fetchingMemberData": { "message": "正在擷取成員資料…" @@ -5626,13 +5659,13 @@ "message": "Send 建立成功!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "複製並分享此 Send 連結。在接下來的 $TIME$ 內,您指定的人員都可以檢視此內容。", + "sendCreatedDescriptionV2": { + "message": "複製並分享此 Send 連結。任何擁有此連結的人,都可在接下來的 $TIME$ 內存取該 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6092,16 +6125,16 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "uriMatchDetectionPolicy": { - "message": "預設的 URI 一致性偵測方式" + "message": "預設 URI 相符偵測" }, "uriMatchDetectionPolicyDesc": { - "message": "決定何時建議登入項目進行自動填入。管理員與擁有者不受此原則限制。" + "message": "用於決定何時提供登入項目的自動填入建議。系統管理員與擁有者不受此原則限制。" }, "uriMatchDetectionOptionsLabel": { - "message": "預設的 URI 一致性偵測方式" + "message": "預設 URI 相符偵測" }, "invalidUriMatchDefaultPolicySetting": { - "message": "請選擇有效的 URI 比對偵測選項。", + "message": "請選擇有效的 URI 相符偵測選項。", "description": "Error message displayed when a user attempts to save URI match detection policy settings with an invalid selection." }, "modifiedPolicyId": { @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "一個或多個組織原則禁止您匯出個人密碼庫。" }, - "activateAutofill": { - "message": "啓用自動填入" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "為所有現有的和新的成員,啓用瀏覽器擴充套件上的頁面載入自動填入設定。" + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "被入侵或不被信任的網站,可能會濫用頁面載入的自動填入功能。" + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "進一步瞭解「自動填入」功能" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "選擇 SSO 類型" @@ -9912,7 +9945,7 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI 匹配偵測是 Bitwarden 用來識別自動填入建議的方式。", + "message": "URI 相符偵測是 Bitwarden 用來判定自動填入建議的方式。", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, "regExAdvancedOptionWarning": { @@ -9924,7 +9957,7 @@ "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "深入了解匹配偵測", + "message": "深入了解相符偵測", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { @@ -10432,6 +10465,9 @@ "datadogEventIntegrationDesc": { "message": "將密碼庫事件資料傳送至你的 Datadog 執行個體" }, + "huntressEventIntegrationDesc": { + "message": "將事件資料傳送至您的 SIEM 執行個體" + }, "failedToSaveIntegration": { "message": "整合設定儲存失敗。請稍後再試。" }, @@ -10543,6 +10579,12 @@ "index": { "message": "索引" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "選擇一個計劃" }, @@ -10926,7 +10968,7 @@ "message": "深入瞭解緊急存取" }, "learnMoreAboutMatchDetection": { - "message": "深入瞭解符合項目偵測" + "message": "瞭解更多關於相符偵測的資訊" }, "learnMoreAboutMasterPasswordReprompt": { "message": "深入瞭解主密碼再次提示" @@ -11357,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden 會在前 72 小時內嘗試宣告該網域 3 次。若無法宣告,請檢查主機上的 DNS 紀錄並手動宣告。若 7 天內仍未宣告,該網域將自你的組織移除。" }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "尚未宣告 $DOMAIN$。請檢查你的 DNS 紀錄。", "placeholders": { @@ -11369,8 +11423,8 @@ "domainStatusClaimed": { "message": "已宣告" }, - "domainStatusUnderVerification": { - "message": "驗證中" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "宣告網域以取得其成員帳號的管理權。擁有已宣告網域的成員在登入時會略過 SSO 識別頁面,且管理員將可刪除已宣告的帳號。" @@ -11873,11 +11927,11 @@ "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'" }, "openExtensionFromToolbarPart1": { - "message": "如果擴充套件沒有開啟,您可能需要從圖示開啟 Bitwarden ", + "message": "若擴充套件沒有開啟,您可能需要透過工具列上的圖示 ", "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'If the extension didn't open, you may need to open Bitwarden from the icon [Bitwarden Icon] on the toolbar.'" }, "openExtensionFromToolbarPart2": { - "message": " 在工具列上。", + "message": "來開啟 Bitwarden。", "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'If the extension didn't open, you may need to open Bitwarden from the icon [Bitwarden Icon] on the toolbar.'" }, "gettingStartedWithBitwardenPart3": { @@ -12092,6 +12146,15 @@ "verifyNow": { "message": "立即驗證" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "額外儲存空間 (GB)" }, @@ -12318,7 +12381,7 @@ "message": "在啟用此原則前,必須先宣告網域。" }, "unlockMethodNeededToChangeTimeoutActionDesc": { - "message": "設定一個解鎖方式來變更您的密碼庫逾時動作。" + "message": "設定解鎖方式以變更您的密碼庫逾時行為。" }, "vaultTimeoutPolicyAffectingOptions": { "message": "企業政策已套用至您的逾時選項中" @@ -12486,7 +12549,7 @@ "message": "於瀏覽器重新整理時" }, "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { - "message": "設定一個解鎖方式來變更您的密碼庫逾時動作。" + "message": "設定解鎖方式,以變更逾時後的行為" }, "leaveConfirmationDialogTitle": { "message": "確定要離開嗎?" @@ -12658,6 +12721,21 @@ "storageFullDescription": { "message": "您已用完全部 $GB$ GB 的加密儲存空間。如需繼續儲存檔案,請增加儲存空間。" }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "當您移除儲存空間時,將會獲得按比例計算的帳戶抵扣金額,並自動套用至下一期帳單。" }, @@ -12666,5 +12744,8 @@ }, "emailProtected": { "message": "電子郵件已受保護" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/tsconfig.build.json b/apps/web/tsconfig.build.json index 273cddb21d2..c1e7a88f4a8 100644 --- a/apps/web/tsconfig.build.json +++ b/apps/web/tsconfig.build.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", "files": ["src/polyfills.ts", "src/main.ts", "src/theme.ts"], - "include": ["src/connectors/*.ts"] + "include": ["src/connectors/*.ts", "src/connectors/platform/*.ts"] } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index fd655b0a56b..6bfa9c8703b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -4,5 +4,10 @@ "strictTemplates": true }, "files": ["src/polyfills.ts", "src/main.ts", "src/theme.ts"], - "include": ["src/connectors/*.ts", "src/**/*.stories.ts", "src/**/*.spec.ts"] + "include": [ + "src/connectors/*.ts", + "src/connectors/platform/*.ts", + "src/**/*.stories.ts", + "src/**/*.spec.ts" + ] } diff --git a/apps/web/webpack.base.js b/apps/web/webpack.base.js index cc17b3b7cfd..2ef9abe09a6 100644 --- a/apps/web/webpack.base.js +++ b/apps/web/webpack.base.js @@ -166,6 +166,11 @@ module.exports.buildConfig = function buildConfig(params) { filename: "duo-redirect-connector.html", chunks: ["connectors/duo-redirect", "styles"], }), + new HtmlWebpackPlugin({ + template: path.resolve(__dirname, "src/connectors/platform/proxy-cookie-redirect.html"), + filename: "proxy-cookie-redirect-connector.html", + chunks: ["connectors/platform/proxy-cookie-redirect", "styles"], + }), new HtmlWebpackPlugin({ template: path.resolve(__dirname, "src/404.html"), filename: "404.html", @@ -319,7 +324,7 @@ module.exports.buildConfig = function buildConfig(params) { https://*.paypal.com https://www.paypalobjects.com https://q.stripe.com - https://haveibeenpwned.com + https://logos.haveibeenpwned.com ;media-src 'self' https://assets.bitwarden.com @@ -403,6 +408,10 @@ module.exports.buildConfig = function buildConfig(params) { "connectors/sso": path.resolve(__dirname, "src/connectors/sso.ts"), "connectors/duo-redirect": path.resolve(__dirname, "src/connectors/duo-redirect.ts"), "connectors/redirect": path.resolve(__dirname, "src/connectors/redirect.ts"), + "connectors/platform/proxy-cookie-redirect": path.resolve( + __dirname, + "src/connectors/platform/proxy-cookie-redirect.ts", + ), styles: [ path.resolve(__dirname, "src/scss/styles.scss"), path.resolve(__dirname, "src/scss/tailwind.css"), diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html index a2b231ffd48..80e76acac1d 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html @@ -10,22 +10,34 @@ {{ "claimDomain" | i18n }} - - {{ data.orgDomain.domainName }} - - - {{ "domainStatusUnderVerification" | i18n }} + {{ "domainStatusPending" | i18n }} {{ "domainStatusClaimed" | i18n }}
+
+

{{ "automaticDomainClaimProcess1" | i18n }}

+

+ {{ "automaticDomainClaimProcess2" | i18n }} + + {{ "accountOwnershipChange" | i18n }} + + + {{ "automaticDomainClaimProcessEnd" | i18n }} +

+
{{ "domainName" | i18n }} - {{ "claimDomainNameInputHint" | i18n }} @@ -40,14 +52,6 @@ (click)="copyDnsTxt()" > - - - {{ "automaticDomainClaimProcess" | i18n }} -
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index d02b44af1be..a2330be4c6f 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -21,7 +21,6 @@ import { Provider } from "@bitwarden/common/admin-console/models/domain/provider import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.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 { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -72,7 +71,6 @@ export class vNextMembersComponent { private activatedRoute = inject(ActivatedRoute); private providerService = inject(ProviderService); private accountService = inject(AccountService); - private configService = inject(ConfigService); private environmentService = inject(EnvironmentService); private providerActionsService = inject(ProviderActionsService); private memberActionsService = inject(MemberActionsService); @@ -94,7 +92,7 @@ export class vNextMembersComponent { protected statusToggle = new BehaviorSubject(undefined); protected readonly dataSource: WritableSignal = signal( - new ProvidersTableDataSource(this.configService, this.environmentService), + new ProvidersTableDataSource(this.environmentService), ); protected readonly firstLoaded: WritableSignal = signal(false); @@ -177,7 +175,7 @@ export class vNextMembersComponent { // Capture the original count BEFORE enforcing the limit const originalInvitedCount = allInvitedUsers.length; - // When feature flag is enabled, limit invited users and uncheck the excess + // In cloud environments, limit invited users and uncheck the excess let checkedInvitedUsers: ProviderUser[]; if (this.dataSource().isIncreasedBulkLimitEnabled()) { checkedInvitedUsers = this.dataSource().limitAndUncheckExcess( @@ -198,7 +196,7 @@ export class vNextMembersComponent { } try { - // When feature flag is enabled, show toast instead of dialog + // In cloud environments, show toast instead of dialog if (this.dataSource().isIncreasedBulkLimitEnabled()) { await this.apiService.postManyProviderUserReinvite( providerId, @@ -226,7 +224,7 @@ export class vNextMembersComponent { }); } } else { - // Feature flag disabled - show legacy dialog + // In self-hosted environments, show legacy dialog const request = this.apiService.postManyProviderUserReinvite( providerId, new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)), diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts index 65f85616dfa..4e391ae8dad 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.ts @@ -7,13 +7,13 @@ import { combineLatest, map, Observable, Subject, switchMap } from "rxjs"; import { takeUntil } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { BusinessUnitPortalLogo, Icon, ProviderPortalLogo } from "@bitwarden/assets/svg"; +import { BusinessUnitPortalLogo, BitSvg, ProviderPortalLogo } from "@bitwarden/assets/svg"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderType } from "@bitwarden/common/admin-console/enums"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; import { TaxIdWarningComponent } from "@bitwarden/web-vault/app/billing/warnings/components"; import { TaxIdWarningType } from "@bitwarden/web-vault/app/billing/warnings/types"; @@ -31,7 +31,7 @@ import { ProviderWarningsService } from "../../billing/providers/warnings/servic RouterModule, JslibModule, WebLayoutModule, - IconModule, + SvgModule, TaxIdWarningComponent, ], }) @@ -41,7 +41,7 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); protected provider$: Observable; - protected logo$: Observable; + protected logo$: Observable; protected canAccessBilling$: Observable; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html index cb8eaea80c3..ff148098cc6 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html @@ -1,10 +1,10 @@
- + >

- + >

= signal([]); private readonly _atRiskCipherIds: Signal = signal([]); private readonly _hasCriticalApplications: Signal = signal(false); + private readonly _reportGeneratedAt: Signal = signal( + undefined, + ); // Computed properties readonly tasksCount = computed(() => this._tasks().length); @@ -81,8 +82,24 @@ export class PasswordChangeMetricComponent implements OnInit { } const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); - const assignedIdSet = new Set(inProgressTasks.map((task) => task.cipherId)); - const unassignedIds = atRiskIds.filter((id) => !assignedIdSet.has(id)); + const inProgressTaskIds = new Set(inProgressTasks.map((task) => task.cipherId)); + + const reportGeneratedAt = this._reportGeneratedAt(); + const completedTasksAfterReportGeneration = reportGeneratedAt + ? tasks.filter( + (task) => + task.status === SecurityTaskStatus.Completed && + new Date(task.revisionDate) >= reportGeneratedAt, + ) + : []; + const completedTaskIds = new Set( + completedTasksAfterReportGeneration.map((task) => task.cipherId), + ); + + // find cipher ids from last report that do not have a corresponding in progress task (awaiting password reset) OR completed task + const unassignedIds = atRiskIds.filter( + (id) => !inProgressTaskIds.has(id) && !completedTaskIds.has(id), + ); return unassignedIds.length; }); @@ -110,36 +127,26 @@ export class PasswordChangeMetricComponent implements OnInit { constructor( private allActivitiesService: AllActivitiesService, private i18nService: I18nService, - private injector: Injector, private riskInsightsDataService: RiskInsightsDataService, protected securityTasksService: AccessIntelligenceSecurityTasksService, private toastService: ToastService, ) { - // Setup the _tasks signal by manually passing in the injector - this._tasks = toSignal(this.securityTasksService.tasks$, { - initialValue: [], - injector: this.injector, - }); - // Setup the _atRiskCipherIds signal by manually passing in the injector + this._tasks = toSignal(this.securityTasksService.tasks$, { initialValue: [] }); this._atRiskCipherIds = toSignal( this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, - { - initialValue: [], - injector: this.injector, - }, + { initialValue: [] }, ); - this._hasCriticalApplications = toSignal( this.riskInsightsDataService.criticalReportResults$.pipe( - takeUntilDestroyed(this.destroyRef), map((report) => { return report != null && (report.reportData?.length ?? 0) > 0; }), ), - { - initialValue: false, - injector: this.injector, - }, + { initialValue: false }, + ); + this._reportGeneratedAt = toSignal( + this.riskInsightsDataService.enrichedReportData$.pipe(map((report) => report?.creationDate)), + { initialValue: undefined }, ); effect(() => { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts index 15d927a7714..619858fdffe 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { @@ -25,7 +24,6 @@ import { DarkImageSourceDirective } from "@bitwarden/vault"; selector: "dirt-assign-tasks-view", templateUrl: "./assign-tasks-view.component.html", imports: [ - CommonModule, ButtonModule, TypographyModule, I18nPipe, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts index 4de8ecd9cd0..796c0acf220 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, @@ -79,7 +78,6 @@ export type NewApplicationsDialogResultType = selector: "dirt-new-applications-dialog", templateUrl: "./new-applications-dialog.component.html", imports: [ - CommonModule, ButtonModule, DialogModule, TypographyModule, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html new file mode 100644 index 00000000000..7c4f6d04a6b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html @@ -0,0 +1,48 @@ +@if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) { + +} @else { + @let drawerDetails = dataService.drawerDetails$ | async; +

+

{{ "allApplications" | i18n }}

+ +
+ + + + + +
+ + +
+} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts new file mode 100644 index 00000000000..8962980c872 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts @@ -0,0 +1,219 @@ +import { + Component, + DestroyRef, + inject, + OnInit, + ChangeDetectionStrategy, + signal, + computed, +} from "@angular/core"; +import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatest, debounceTime, startWith } from "rxjs"; + +import { Security } from "@bitwarden/assets/svg"; +import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; +import { + OrganizationReportSummary, + ReportStatus, +} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ButtonModule, + IconButtonModule, + LinkModule, + NoItemsModule, + SearchModule, + TableDataSource, + ToastService, + TypographyModule, + ChipSelectComponent, +} from "@bitwarden/components"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; + +import { AppTableRowScrollableM11Component } from "../shared/app-table-row-scrollable-m11.component"; +import { ApplicationTableDataSource } from "../shared/app-table-row-scrollable.component"; +import { ReportLoadingComponent } from "../shared/report-loading.component"; + +export const ApplicationFilterOption = { + All: "all", + Critical: "critical", + NonCritical: "nonCritical", +} as const; + +export type ApplicationFilterOption = + (typeof ApplicationFilterOption)[keyof typeof ApplicationFilterOption]; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "dirt-applications", + templateUrl: "./applications.component.html", + imports: [ + ReportLoadingComponent, + HeaderModule, + LinkModule, + SearchModule, + PipesModule, + NoItemsModule, + SharedModule, + AppTableRowScrollableM11Component, + IconButtonModule, + TypographyModule, + ButtonModule, + ReactiveFormsModule, + ChipSelectComponent, + ], +}) +export class ApplicationsComponent implements OnInit { + destroyRef = inject(DestroyRef); + + protected ReportStatusEnum = ReportStatus; + protected noItemsIcon = Security; + + // Standard properties + protected readonly dataSource = new TableDataSource(); + protected readonly searchControl = new FormControl("", { nonNullable: true }); + + // Template driven properties + protected readonly selectedUrls = signal(new Set()); + protected readonly markingAsCritical = signal(false); + protected readonly applicationSummary = signal(createNewSummaryData()); + protected readonly criticalApplicationsCount = signal(0); + protected readonly totalApplicationsCount = signal(0); + protected readonly nonCriticalApplicationsCount = computed(() => { + return this.totalApplicationsCount() - this.criticalApplicationsCount(); + }); + + // filter related properties + protected readonly selectedFilter = signal(ApplicationFilterOption.All); + protected selectedFilterObservable = toObservable(this.selectedFilter); + protected readonly ApplicationFilterOption = ApplicationFilterOption; + protected readonly filterOptions = computed(() => [ + { + label: this.i18nService.t("critical", this.criticalApplicationsCount()), + value: ApplicationFilterOption.Critical, + }, + { + label: this.i18nService.t("notCritical", this.nonCriticalApplicationsCount()), + value: ApplicationFilterOption.NonCritical, + }, + ]); + + constructor( + protected i18nService: I18nService, + protected activatedRoute: ActivatedRoute, + protected toastService: ToastService, + protected dataService: RiskInsightsDataService, + ) {} + + async ngOnInit() { + this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (report) => { + if (report != null) { + this.applicationSummary.set(report.summaryData); + + // Map the report data to include the iconCipher for each application + const tableDataWithIcon = report.reportData.map((app) => ({ + ...app, + iconCipher: + app.cipherIds.length > 0 + ? this.dataService.getCipherIcon(app.cipherIds[0]) + : undefined, + })); + this.dataSource.data = tableDataWithIcon; + this.totalApplicationsCount.set(report.reportData.length); + } else { + this.dataSource.data = []; + } + }, + error: () => { + this.dataSource.data = []; + }, + }); + + this.dataService.criticalReportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (criticalReport) => { + if (criticalReport != null) { + this.criticalApplicationsCount.set(criticalReport.reportData.length); + } else { + this.criticalApplicationsCount.set(0); + } + }, + }); + + combineLatest([ + this.searchControl.valueChanges.pipe(startWith("")), + this.selectedFilterObservable, + ]) + .pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)) + .subscribe(([searchText, selectedFilter]) => { + let filterFunction = (app: ApplicationTableDataSource) => true; + + if (selectedFilter === ApplicationFilterOption.Critical) { + filterFunction = (app) => app.isMarkedAsCritical; + } else if (selectedFilter === ApplicationFilterOption.NonCritical) { + filterFunction = (app) => !app.isMarkedAsCritical; + } + + this.dataSource.filter = (app) => + filterFunction(app) && + app.applicationName.toLowerCase().includes(searchText.toLowerCase()); + }); + } + + setFilterApplicationsByStatus(value: ApplicationFilterOption) { + this.selectedFilter.set(value); + } + + isMarkedAsCriticalItem(applicationName: string) { + return this.selectedUrls().has(applicationName); + } + + markAppsAsCritical = async () => { + this.markingAsCritical.set(true); + const count = this.selectedUrls().size; + + this.dataService + .saveCriticalApplications(Array.from(this.selectedUrls())) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()), + }); + this.selectedUrls.set(new Set()); + this.markingAsCritical.set(false); + }, + error: () => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("applicationsMarkedAsCriticalFail"), + }); + }, + }); + }; + + showAppAtRiskMembers = async (applicationName: string) => { + await this.dataService.setDrawerForAppAtRiskMembers(applicationName); + }; + + onCheckboxChange = (applicationName: string, event: Event) => { + const isChecked = (event.target as HTMLInputElement).checked; + this.selectedUrls.update((selectedUrls) => { + const nextSelected = new Set(selectedUrls); + if (isChecked) { + nextSelected.add(applicationName); + } else { + nextSelected.delete(applicationName); + } + return nextSelected; + }); + }; +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html index 42600671e8c..59aa680fa4e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html @@ -6,12 +6,11 @@ {{ title() }}
-
- {{ description() }} -
+ @if (description()) { +
+ {{ description() }} +
+ } @if (benefits().length > 0) {
@for (benefit of benefits(); track $index) { @@ -38,69 +37,74 @@
} -
- -
+ @if (buttonText() && buttonAction()) { +
+ +
+ }
-
-
- @if (videoSrc()) { - - } @else if (icon()) { -
- +
+ @if (videoSrc()) { +
- } + > + } @else if (icon()) { +
+ +
+ } +
-
- -
-
- @if (videoSrc()) { - - } @else if (icon()) { -
- +
+ @if (videoSrc()) { +
- } + > + } @else if (icon()) { +
+ +
+ } +
-
+ }
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts index 54d97e984ec..a9ad86dc67c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts @@ -1,17 +1,16 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input, isDevMode, OnInit } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; -import { ButtonModule, IconModule } from "@bitwarden/components"; +import { BitSvg } from "@bitwarden/assets/svg"; +import { ButtonModule, SvgModule } from "@bitwarden/components"; @Component({ selector: "empty-state-card", templateUrl: "./empty-state-card.component.html", - imports: [CommonModule, IconModule, ButtonModule], + imports: [SvgModule, ButtonModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class EmptyStateCardComponent implements OnInit { - readonly icon = input(null); + readonly icon = input(null); readonly videoSrc = input(null); readonly title = input(""); readonly description = input(""); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index dfbd49d95f7..1e58d334288 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -44,10 +44,11 @@
-
- {{ "reviewAtRiskPasswords" | i18n }} -
- @let isRunningReport = dataService.isGeneratingReport$ | async; + @if (appsCount > 0) { +
+ {{ "reviewAtRiskPasswords" | i18n }} +
+ }
@@ -62,7 +63,6 @@ } - - -
@@ -88,6 +81,11 @@ + @if (milestone11Enabled) { + + + + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index b307c91d29f..657bdb87d4a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -21,6 +21,8 @@ import { ReportStatus, RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +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"; @@ -38,6 +40,7 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { AllActivityComponent } from "./activity/all-activity.component"; import { AllApplicationsComponent } from "./all-applications/all-applications.component"; +import { ApplicationsComponent } from "./all-applications/applications.component"; import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; import { EmptyStateCardComponent } from "./empty-state-card.component"; import { RiskInsightsTabType } from "./models/risk-insights.models"; @@ -53,6 +56,7 @@ type ProgressStep = ReportProgress | null; templateUrl: "./risk-insights.component.html", imports: [ AllApplicationsComponent, + ApplicationsComponent, AsyncActionsModule, ButtonModule, CommonModule, @@ -77,6 +81,7 @@ type ProgressStep = ReportProgress | null; export class RiskInsightsComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); protected ReportStatusEnum = ReportStatus; + protected milestone11Enabled: boolean = false; tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllActivity; @@ -114,6 +119,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { protected dialogService: DialogService, private fileDownloadService: FileDownloadService, private logService: LogService, + private configService: ConfigService, ) { this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllActivity; @@ -121,6 +127,10 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { } async ngOnInit() { + this.milestone11Enabled = await this.configService.getFeatureFlag( + FeatureFlag.Milestone11AppPageImprovements, + ); + this.route.paramMap .pipe( takeUntilDestroyed(this.destroyRef), diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html new file mode 100644 index 00000000000..4f231efc04b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html @@ -0,0 +1,143 @@ + + + + + + {{ "application" | i18n }} + + {{ "atRiskPasswords" | i18n }} + + {{ "totalPasswords" | i18n }} + {{ "atRiskMembers" | i18n }} + {{ "totalMembers" | i18n }} + + + + + + + @if (row.iconCipher) { + + } + + +
+
+ {{ row.applicationName }} +
+ @if (row.isMarkedAsCritical) { + {{ "criticalBadge" | i18n }} + } +
+ + + + {{ row.atRiskPasswordCount }} + + + + + {{ row.passwordCount }} + + + + + {{ row.atRiskMemberCount }} + + + + {{ row.memberCount }} + + @if (showRowMenuForCriticalApps) { + + + + + + + } +
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.ts new file mode 100644 index 00000000000..ef870bd5b38 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.ts @@ -0,0 +1,44 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { MenuModule, TableDataSource, TableModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; + +import { ApplicationTableDataSource } from "./app-table-row-scrollable.component"; + +//TODO: Rename this component to AppTableRowScrollableComponent once milestone 11 is fully rolled out +//TODO: Move definition of ApplicationTableDataSource to this file from app-table-row-scrollable.component.ts + +// 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-table-row-scrollable-m11", + imports: [CommonModule, JslibModule, TableModule, SharedModule, PipesModule, MenuModule], + templateUrl: "./app-table-row-scrollable-m11.component.html", +}) +export class AppTableRowScrollableM11Component { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + dataSource!: TableDataSource; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() showRowMenuForCriticalApps: boolean = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() selectedUrls: Set = new Set(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() openApplication: string = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() showAppAtRiskMembers!: (applicationName: string) => void; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() unmarkAsCritical!: (applicationName: string) => void; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() checkboxChange!: (applicationName: string, $event: Event) => void; +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html index 0494f77bd46..0a72c76a550 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html @@ -12,28 +12,32 @@ {{ "totalMembers" | i18n }} - - - - - - - + @if (showRowCheckBox) { + + @if (!row.isMarkedAsCritical) { + + } + @if (row.isMarkedAsCritical) { + + } + + } + @if (!showRowCheckBox) { + + @if (row.isMarkedAsCritical) { + + } + + } - + @if (row.iconCipher) { + + } {{ row.memberCount }} - - - - - - - + @if (showRowMenuForCriticalApps) { + + + + + + + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts index f3cb89dff55..45b28dae470 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { Component, input } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -19,7 +18,7 @@ const ProgressStepConfig = Object.freeze({ // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "dirt-report-loading", - imports: [CommonModule, JslibModule, ProgressModule], + imports: [JslibModule, ProgressModule], templateUrl: "./report-loading.component.html", }) export class ReportLoadingComponent { diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html index 9e14023d21b..8127c6a0343 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html @@ -1,21 +1,22 @@
    -
  • - -
  • + @for (integration of integrations; track integration) { +
  • + +
  • + }
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html index a35df3677bb..14f20a0b71c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html @@ -24,28 +24,32 @@ @if (organization?.useScim || organization?.useDirectory) { -
-

- {{ "scimIntegration" | i18n }} -

-

- {{ "scimIntegrationDescStart" | i18n }} - {{ "scimIntegration" | i18n }} - {{ "scimIntegrationDescEnd" | i18n }} -

- -
-
-

- {{ "bwdc" | i18n }} -

-

{{ "bwdcDesc" | i18n }}

- -
+ @if (organization?.useScim) { +
+

+ {{ "scimIntegration" | i18n }} +

+

+ {{ "scimIntegrationDescStart" | i18n }} + {{ "scimIntegration" | i18n }} + {{ "scimIntegrationDescEnd" | i18n }} +

+ +
+ } + @if (organization?.useDirectory) { +
+

+ {{ "bwdc" | i18n }} +

+

{{ "bwdcDesc" | i18n }}

+ +
+ }
} diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html index 0200e206327..440e955a226 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html @@ -1,21 +1,17 @@ - + @let isLoading = isLoading$ | async; - + @if (!isLoading) { + + + }
@@ -24,7 +20,7 @@

- +@if (isLoading) {

{{ "loading" | i18n }}

-
- - - {{ "members" | i18n }} - {{ "groups" | i18n }} - {{ "collections" | i18n }} - {{ "items" | i18n }} - - - -
- -
- - -
- {{ row.email }} +} @else { + + + {{ "members" | i18n }} + {{ "groups" | i18n }} + + {{ "collections" | i18n }} + + {{ "items" | i18n }} + + + +
+ +
+ +
+ {{ row.email }} +
-
- - {{ row.groupsCount }} - {{ row.collectionsCount }} - {{ row.itemsCount }} - - + + {{ row.groupsCount }} + {{ row.collectionsCount }} + {{ row.itemsCount }} + + +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts index f2e0d48fe1d..241f02fce7e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts @@ -2,7 +2,7 @@ import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { map, concatMap, firstValueFrom } from "rxjs"; -import { Icon, DeactivatedOrg } from "@bitwarden/assets/svg"; +import { BitSvg, DeactivatedOrg } from "@bitwarden/assets/svg"; import { getOrganizationById, OrganizationService, @@ -23,7 +23,7 @@ export class OrgSuspendedComponent { private route: ActivatedRoute, ) {} - protected DeactivatedOrg: Icon = DeactivatedOrg; + protected DeactivatedOrg: BitSvg = DeactivatedOrg; protected organizationName$ = this.route.params.pipe( concatMap(async (params) => { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); diff --git a/bitwarden_license/bit-web/tsconfig.build.json b/bitwarden_license/bit-web/tsconfig.build.json index 58acbf09392..cc55f69bc4f 100644 --- a/bitwarden_license/bit-web/tsconfig.build.json +++ b/bitwarden_license/bit-web/tsconfig.build.json @@ -9,5 +9,5 @@ "../../bitwarden_license/bit-web/src/main.ts" ], - "include": ["../../apps/web/src/connectors/*.ts"] + "include": ["../../apps/web/src/connectors/*.ts", "../../apps/web/src/connectors/platform/*.ts"] } diff --git a/bitwarden_license/bit-web/tsconfig.json b/bitwarden_license/bit-web/tsconfig.json index 8c19f771a26..8dcd128ae6b 100644 --- a/bitwarden_license/bit-web/tsconfig.json +++ b/bitwarden_license/bit-web/tsconfig.json @@ -11,6 +11,7 @@ ], "include": [ "../../apps/web/src/connectors/*.ts", + "../../apps/web/src/connectors/platform/*.ts", "../../apps/web/src/**/*.stories.ts", "../../apps/web/src/**/*.spec.ts", "src/**/*.stories.ts", diff --git a/eslint.config.mjs b/eslint.config.mjs index e8f43d4a9ea..974aaafeef6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -207,6 +207,7 @@ export default tseslint.config( "error", { ignoreIfHas: ["bitPasswordInputToggle"] }, ], + "@bitwarden/components/no-bwi-class-usage": "warn", }, }, diff --git a/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts b/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts index cbaece1b442..1dc0e0b3bef 100644 --- a/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts +++ b/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts @@ -10,6 +10,8 @@ import { OrganizationUserResetPasswordRequest, OrganizationUserUpdateRequest, } from "../models/requests"; +import { OrganizationUserBulkRestoreRequest } from "../models/requests/organization-user-bulk-restore.request"; +import { OrganizationUserRestoreRequest } from "../models/requests/organization-user-restore.request"; import { OrganizationUserBulkPublicKeyResponse, OrganizationUserBulkResponse, @@ -278,6 +280,18 @@ export abstract class OrganizationUserApiService { */ abstract restoreOrganizationUser(organizationId: string, id: string): Promise; + /** + * Restore an organization user's access to the organization + * @param organizationId - Identifier for the organization the user belongs to + * @param id - Organization user identifier + * @param request - Restore request containing default user collection name + */ + abstract restoreOrganizationUser_vNext( + organizationId: string, + id: string, + request: OrganizationUserRestoreRequest, + ): Promise; + /** * Restore many organization users' access to the organization * @param organizationId - Identifier for the organization the users belongs to @@ -289,6 +303,17 @@ export abstract class OrganizationUserApiService { ids: string[], ): Promise>; + /** + * Restore many organization users' access to the organization + * @param organizationId - Identifier for the organization the users belongs to + * @param request - Restore request containing default user collection name + * @return List of user ids, including both those that were successfully restored and those that had an error + */ + abstract restoreManyOrganizationUsers_vNext( + organizationId: string, + request: OrganizationUserBulkRestoreRequest, + ): Promise>; + /** * Remove an organization user's access to the organization and delete their account data * @param organizationId - Identifier for the organization the user belongs to diff --git a/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts b/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts index 844a0f412be..03e6840d786 100644 --- a/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts +++ b/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts @@ -42,4 +42,11 @@ export abstract class OrganizationUserService { organization: Organization, userIdsWithKeys: { id: string; key: string }[], ): Observable>; + + abstract restoreUser(organization: Organization, userId: string): Observable; + + abstract bulkRestoreUsers( + organization: Organization, + userIds: string[], + ): Observable>; } diff --git a/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-restore.request.ts b/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-restore.request.ts new file mode 100644 index 00000000000..74a91897a58 --- /dev/null +++ b/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-restore.request.ts @@ -0,0 +1,11 @@ +import { EncString } from "@bitwarden/sdk-internal"; + +export class OrganizationUserBulkRestoreRequest { + userIds: string[]; + defaultUserCollectionName: EncString | undefined; + + constructor(userIds: string[], defaultUserCollectionName?: EncString) { + this.userIds = userIds; + this.defaultUserCollectionName = defaultUserCollectionName; + } +} diff --git a/libs/admin-console/src/common/organization-user/models/requests/organization-user-restore.request.ts b/libs/admin-console/src/common/organization-user/models/requests/organization-user-restore.request.ts new file mode 100644 index 00000000000..c4607065845 --- /dev/null +++ b/libs/admin-console/src/common/organization-user/models/requests/organization-user-restore.request.ts @@ -0,0 +1,9 @@ +import { EncString } from "@bitwarden/sdk-internal"; + +export class OrganizationUserRestoreRequest { + defaultUserCollectionName: EncString | undefined; + + constructor(defaultUserCollectionName?: EncString) { + this.defaultUserCollectionName = defaultUserCollectionName; + } +} diff --git a/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts index 536afd2b3f6..e5609f75251 100644 --- a/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts @@ -13,6 +13,8 @@ import { OrganizationUserUpdateRequest, OrganizationUserBulkRequest, } from "../models/requests"; +import { OrganizationUserBulkRestoreRequest } from "../models/requests/organization-user-bulk-restore.request"; +import { OrganizationUserRestoreRequest } from "../models/requests/organization-user-restore.request"; import { OrganizationUserBulkPublicKeyResponse, OrganizationUserBulkResponse, @@ -359,6 +361,20 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer ); } + restoreOrganizationUser_vNext( + organizationId: string, + id: string, + request: OrganizationUserRestoreRequest, + ): Promise { + return this.apiService.send( + "PUT", + "/organizations/" + organizationId + "/users/" + id + "/restore/vnext", + request, + true, + false, + ); + } + async restoreManyOrganizationUsers( organizationId: string, ids: string[], @@ -373,6 +389,20 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer return new ListResponse(r, OrganizationUserBulkResponse); } + async restoreManyOrganizationUsers_vNext( + organizationId: string, + request: OrganizationUserBulkRestoreRequest, + ): Promise> { + const r = await this.apiService.send( + "PUT", + "/organizations/" + organizationId + "/users/restore", + request, + true, + true, + ); + return new ListResponse(r, OrganizationUserBulkResponse); + } + deleteOrganizationUser(organizationId: string, id: string): Promise { return this.apiService.send( "DELETE", diff --git a/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts index 982fb3ca5e0..0448b23e4d2 100644 --- a/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts @@ -61,6 +61,8 @@ describe("DefaultOrganizationUserService", () => { organizationUserApiService = { postOrganizationUserConfirm: jest.fn(), postOrganizationUserBulkConfirm: jest.fn(), + restoreOrganizationUser_vNext: jest.fn(), + restoreManyOrganizationUsers_vNext: jest.fn(), } as any; accountService = { @@ -174,4 +176,97 @@ describe("DefaultOrganizationUserService", () => { }); }); }); + + describe("buildRestoreUserRequest", () => { + beforeEach(() => { + setupCommonMocks(); + }); + + it("should build a restore request with encrypted collection name", (done) => { + service.buildRestoreUserRequest(mockOrganization).subscribe({ + next: (request) => { + expect(i18nService.t).toHaveBeenCalledWith("myItems"); + expect(encryptService.encryptString).toHaveBeenCalledWith( + mockDefaultCollectionName, + mockOrgKey, + ); + expect(request).toEqual({ + defaultUserCollectionName: mockEncryptedCollectionName.encryptedString, + }); + done(); + }, + error: done, + }); + }); + }); + + describe("restoreUser", () => { + beforeEach(() => { + setupCommonMocks(); + organizationUserApiService.restoreOrganizationUser_vNext.mockReturnValue(Promise.resolve()); + }); + + it("should restore a user successfully", (done) => { + service.restoreUser(mockOrganization, mockUserId).subscribe({ + next: () => { + expect(i18nService.t).toHaveBeenCalledWith("myItems"); + expect(encryptService.encryptString).toHaveBeenCalledWith( + mockDefaultCollectionName, + mockOrgKey, + ); + expect(organizationUserApiService.restoreOrganizationUser_vNext).toHaveBeenCalledWith( + mockOrganization.id, + mockUserId, + { + defaultUserCollectionName: mockEncryptedCollectionName.encryptedString, + }, + ); + done(); + }, + error: done, + }); + }); + }); + + describe("bulkRestoreUsers", () => { + const mockUserIds = ["user-1", "user-2"]; + + const mockBulkResponse = { + data: [ + { id: "user-1", error: null } as OrganizationUserBulkResponse, + { id: "user-2", error: null } as OrganizationUserBulkResponse, + ], + } as ListResponse; + + beforeEach(() => { + setupCommonMocks(); + organizationUserApiService.restoreManyOrganizationUsers_vNext.mockReturnValue( + Promise.resolve(mockBulkResponse), + ); + }); + + it("should bulk restore users successfully", (done) => { + service.bulkRestoreUsers(mockOrganization, mockUserIds).subscribe({ + next: (response) => { + expect(i18nService.t).toHaveBeenCalledWith("myItems"); + expect(encryptService.encryptString).toHaveBeenCalledWith( + mockDefaultCollectionName, + mockOrgKey, + ); + expect( + organizationUserApiService.restoreManyOrganizationUsers_vNext, + ).toHaveBeenCalledWith( + mockOrganization.id, + expect.objectContaining({ + userIds: mockUserIds, + defaultUserCollectionName: mockEncryptedCollectionName.encryptedString, + }), + ); + expect(response).toEqual(mockBulkResponse); + done(); + }, + error: done, + }); + }); + }); }); diff --git a/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts index 4f503a92675..d54743e2f7b 100644 --- a/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts @@ -1,10 +1,10 @@ import { combineLatest, filter, map, Observable, switchMap } from "rxjs"; import { - OrganizationUserConfirmRequest, - OrganizationUserBulkConfirmRequest, OrganizationUserApiService, + OrganizationUserBulkConfirmRequest, OrganizationUserBulkResponse, + OrganizationUserConfirmRequest, OrganizationUserService, } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -16,6 +16,9 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { OrganizationId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; +import { OrganizationUserBulkRestoreRequest } from "../models/requests/organization-user-bulk-restore.request"; +import { OrganizationUserRestoreRequest } from "../models/requests/organization-user-restore.request"; + export class DefaultOrganizationUserService implements OrganizationUserService { constructor( protected keyService: KeyService, @@ -83,6 +86,43 @@ export class DefaultOrganizationUserService implements OrganizationUserService { ); } + buildRestoreUserRequest(organization: Organization): Observable { + return this.getEncryptedDefaultCollectionName$(organization).pipe( + map((collectionName) => new OrganizationUserRestoreRequest(collectionName.encryptedString)), + ); + } + + restoreUser(organization: Organization, userId: string): Observable { + return this.buildRestoreUserRequest(organization).pipe( + switchMap((request) => + this.organizationUserApiService.restoreOrganizationUser_vNext( + organization.id, + userId, + request, + ), + ), + ); + } + + bulkRestoreUsers( + organization: Organization, + userIds: string[], + ): Observable> { + return this.getEncryptedDefaultCollectionName$(organization).pipe( + switchMap((collectionName) => { + const request = new OrganizationUserBulkRestoreRequest( + userIds, + collectionName.encryptedString, + ); + + return this.organizationUserApiService.restoreManyOrganizationUsers_vNext( + organization.id, + request, + ); + }), + ); + } + private getEncryptedDefaultCollectionName$(organization: Organization) { return this.orgKey$(organization).pipe( switchMap((orgKey) => diff --git a/libs/angular/src/auth/components/two-factor-icon.component.html b/libs/angular/src/auth/components/two-factor-icon.component.html index 14558700757..555176225af 100644 --- a/libs/angular/src/auth/components/two-factor-icon.component.html +++ b/libs/angular/src/auth/components/two-factor-icon.component.html @@ -1,6 +1,6 @@
- +
{ expect(sut).not.toBeFalsy(); }); + /** + * @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties + * in the test setup above that are now un-used and can also be removed. + */ describe("setInitialPassword(...)", () => { // Mock function parameters let credentials: SetInitialPasswordCredentials; diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts index 4ab26ecd09e..7850a980eef 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts @@ -29,6 +29,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { @@ -38,6 +39,7 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; import { I18nPipe } from "@bitwarden/ui-common"; import { @@ -76,6 +78,7 @@ export class SetInitialPasswordComponent implements OnInit { private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, private dialogService: DialogService, private i18nService: I18nService, + private keyService: KeyService, private logoutService: LogoutService, private logService: LogService, private masterPasswordService: InternalMasterPasswordServiceAbstraction, @@ -110,16 +113,72 @@ export class SetInitialPasswordComponent implements OnInit { switch (this.userType) { case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: { + /** + * "KM flag" = EnableAccountEncryptionV2JitPasswordRegistration + * "Auth flag" = PM27086_UpdateAuthenticationApisForInputPassword (checked in InputPasswordComponent and + * passed through via PasswordInputResult) + * + * Flag unwinding for this specific `case` will depend on which flag gets unwound first: + * - If KM flag gets unwound first, remove all code (in this `case`) after the call + * to setInitialPasswordJitMPUserV2Encryption(), as the V2Encryption method is the + * end-goal for this `case`. + * - If Auth flag gets unwound first (in PM-28143), keep the KM code & early return, + * but unwind the auth flagging logic and then remove the method call marked with + * the "Default Scenario" comment. + */ + const accountEncryptionV2 = await this.configService.getFeatureFlag( FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration, ); + // Scenario 1: KM flag ON if (accountEncryptionV2) { await this.setInitialPasswordJitMPUserV2Encryption(passwordInputResult); return; } - await this.setInitialPassword(passwordInputResult); + // Scenario 2: KM flag OFF, Auth flag ON + if (passwordInputResult.newApisWithInputPasswordFlagEnabled) { + /** + * If the Auth flag is enabled, it means the InputPasswordComponent will not emit a newMasterKey, + * newServerMasterKeyHash, and newLocalMasterKeyHash. So we must create them here and add them late + * to the PasswordInputResult before calling setInitialPassword(). + * + * This is a temporary state. The end-goal will be to use KM's V2Encryption method above. + */ + const ctx = "Could not set initial password."; + assertTruthy(passwordInputResult.newPassword, "newPassword", ctx); + assertNonNullish(passwordInputResult.kdfConfig, "kdfConfig", ctx); + assertTruthy(this.email, "email", ctx); + + const newMasterKey = await this.keyService.makeMasterKey( + passwordInputResult.newPassword, + this.email.trim().toLowerCase(), + passwordInputResult.kdfConfig, + ); + + const newServerMasterKeyHash = await this.keyService.hashMasterKey( + passwordInputResult.newPassword, + newMasterKey, + HashPurpose.ServerAuthorization, + ); + + const newLocalMasterKeyHash = await this.keyService.hashMasterKey( + passwordInputResult.newPassword, + newMasterKey, + HashPurpose.LocalAuthorization, + ); + + passwordInputResult.newMasterKey = newMasterKey; + passwordInputResult.newServerMasterKeyHash = newServerMasterKeyHash; + passwordInputResult.newLocalMasterKeyHash = newLocalMasterKeyHash; + + await this.setInitialPassword(passwordInputResult); // passwordInputResult masterKey properties generated on the SetInitialPasswordComponent (just above) + return; + } + + // Default Scenario: both flags OFF + await this.setInitialPassword(passwordInputResult); // passwordInputResult masterKey properties generated on the InputPasswordComponent (default) break; } @@ -274,6 +333,9 @@ export class SetInitialPasswordComponent implements OnInit { } } + /** + * @deprecated To be removed in PM-28143 + */ private async setInitialPassword(passwordInputResult: PasswordInputResult) { const ctx = "Could not set initial password."; assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx); diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts index 2667040c707..70318be3393 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts @@ -87,6 +87,8 @@ export interface InitializeJitPasswordCredentials { */ export abstract class SetInitialPasswordService { /** + * @deprecated To be removed in PM-28143 + * * Sets an initial password for an existing authed user who is either: * - {@link SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER} * - {@link SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP} diff --git a/libs/angular/src/billing/types/subscription-pricing-card-details.ts b/libs/angular/src/billing/types/subscription-pricing-card-details.ts index fb13706a69a..5f37f91c4f0 100644 --- a/libs/angular/src/billing/types/subscription-pricing-card-details.ts +++ b/libs/angular/src/billing/types/subscription-pricing-card-details.ts @@ -1,10 +1,14 @@ import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier"; -import { ButtonType } from "@bitwarden/components"; +import { BitwardenIcon, ButtonType } from "@bitwarden/components"; export type SubscriptionPricingCardDetails = { title: string; tagline: string; - price?: { amount: number; cadence: SubscriptionCadence; showPerUser?: boolean }; - button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } }; + price?: { amount: number; cadence: SubscriptionCadence }; + button: { + text: string; + type: ButtonType; + icon?: { type: BitwardenIcon; position: "before" | "after" }; + }; features: string[]; }; diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index 8d222a4aaf9..c3670148d67 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -11,7 +11,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, RadioButtonModule, @@ -73,9 +73,9 @@ import { IconComponent } from "./vault/components/icon.component"; MenuModule, NoItemsModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, - IconModule, + SvgModule, TextDragDirective, CopyClickDirective, A11yTitleDirective, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index cf41b28baca..5a582626e68 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -303,6 +303,7 @@ import { import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; +import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; @@ -321,6 +322,7 @@ import { CipherAuthorizationService, DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service"; import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; @@ -590,6 +592,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultDomainSettingsService, deps: [StateProvider, PolicyServiceAbstraction, AccountService], }), + safeProvider({ + provide: CipherSdkService, + useClass: DefaultCipherSdkService, + deps: [SdkService, LogService], + }), safeProvider({ provide: CipherServiceAbstraction, useFactory: ( @@ -607,6 +614,7 @@ const safeProviders: SafeProvider[] = [ logService: LogService, cipherEncryptionService: CipherEncryptionService, messagingService: MessagingServiceAbstraction, + cipherSdkService: CipherSdkService, ) => new CipherService( keyService, @@ -623,6 +631,7 @@ const safeProviders: SafeProvider[] = [ logService, cipherEncryptionService, messagingService, + cipherSdkService, ), deps: [ KeyService, @@ -639,6 +648,7 @@ const safeProviders: SafeProvider[] = [ LogService, CipherEncryptionService, MessagingServiceAbstraction, + CipherSdkService, ], }), safeProvider({ @@ -848,6 +858,8 @@ const safeProviders: SafeProvider[] = [ KeyGenerationService, SendStateProviderAbstraction, EncryptService, + CryptoFunctionServiceAbstraction, + ConfigService, ], }), safeProvider({ @@ -886,7 +898,7 @@ const safeProviders: SafeProvider[] = [ FolderApiServiceAbstraction, InternalOrganizationServiceAbstraction, SendApiServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, + InternalUserDecryptionOptionsServiceAbstraction, AvatarServiceAbstraction, LOGOUT_CALLBACK, BillingAccountProfileStateService, diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index 563fd48028d..c4fe2741e11 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -194,12 +194,7 @@ export class VaultItemsComponent implements OnDestroy return this.searchService.searchCiphers( userId, searchText, - [ - filter, - this.deletedFilter, - ...(this.deleted ? [] : [this.archivedFilter]), - restrictedTypeFilter, - ], + [filter, restrictedTypeFilter], allCiphers, ); }), diff --git a/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts index 835c9e35ac7..ab8a1869266 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts @@ -39,7 +39,7 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService { this.getNudgeStatus$(nudgeType, userId), of(Date.now() - THIRTY_DAYS_MS), from(this.pinService.isPinSet(userId)), - this.biometricStateService.biometricUnlockEnabled$, + this.biometricStateService.biometricUnlockEnabled$(userId), this.organizationService.organizations$(userId), this.policyService.policiesByType$(PolicyType.RemoveUnlockWithPin, userId), ]).pipe( diff --git a/libs/angular/src/vault/services/custom-nudges-services/has-items-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/has-items-nudge.service.ts index d030b37dbd1..336aead0e8c 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/has-items-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/has-items-nudge.service.ts @@ -1,8 +1,8 @@ import { inject, Injectable } from "@angular/core"; -import { combineLatest, from, Observable, of, switchMap } from "rxjs"; -import { catchError } from "rxjs/operators"; +import { combineLatest, Observable, of, switchMap } from "rxjs"; +import { catchError, map } from "rxjs/operators"; -import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -20,11 +20,14 @@ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; }) export class HasItemsNudgeService extends DefaultSingleNudgeService { cipherService = inject(CipherService); - vaultProfileService = inject(VaultProfileService); + accountService = inject(AccountService); logService = inject(LogService); nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { - const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe( + const profileDate$ = this.accountService.activeAccount$.pipe( + map((account) => { + return account?.creationDate ?? new Date(); + }), catchError(() => { this.logService.error("Error getting profile creation date"); // Default to today to ensure we show the nudge diff --git a/libs/angular/src/vault/services/vault-profile.service.ts b/libs/angular/src/vault/services/vault-profile.service.ts index 3a8c9d4ee95..3977b275d02 100644 --- a/libs/angular/src/vault/services/vault-profile.service.ts +++ b/libs/angular/src/vault/services/vault-profile.service.ts @@ -21,6 +21,9 @@ export class VaultProfileService { * Returns the creation date of the profile. * Note: `Date`s are mutable in JS, creating a new * instance is important to avoid unwanted changes. + * + * @deprecated use `creationDate` directly from the `AccountService.activeAccount$` instead, + * PM-31409 will replace all usages of this service. */ async getProfileCreationDate(userId: string): Promise { if (this.profileCreatedDate && userId === this.userId) { diff --git a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts index 83693c85239..d3ad29142e2 100644 --- a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts +++ b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts @@ -54,6 +54,12 @@ export class VaultFilter { cipherPassesFilter = CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher); } + + if (this.status !== "archive" && this.status !== "trash" && cipherPassesFilter) { + cipherPassesFilter = + !CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher); + } + if (this.cipherType != null && cipherPassesFilter) { cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType; } diff --git a/libs/assets/README.md b/libs/assets/README.md index 60df3f8992a..944c942640a 100644 --- a/libs/assets/README.md +++ b/libs/assets/README.md @@ -8,9 +8,9 @@ This lib contains assets used by the Bitwarden clients. Unused assets are tree-s ### SVGs -SVGs intended to be used with the `bit-icon` component live in `src/svgs`. These SVGs are built with the `icon-service` for security reasons. These SVGs can be viewed in our Component Library [Icon Story](https://components.bitwarden.com/?path=/story/component-library-icon--default). +SVGs intended to be used with the `bit-svg` component live in `src/svgs`. These SVGs are built with the `svg` function for security reasons. These SVGs can be viewed in our Component Library [SVG Story](https://components.bitwarden.com/?path=/story/component-library-svg--default). -When adding a new SVG, follow the instructions in our Component Library: [SVG Icon Docs](https://components.bitwarden.com/?path=/docs/component-library-icon--docs) +When adding a new SVG, follow the instructions in our Component Library: [SVG Docs](https://components.bitwarden.com/?path=/docs/component-library-svg--docs) When importing an SVG in one of the clients: `import { ExampleSvg } from "@bitwarden/assets/svg";` diff --git a/libs/assets/src/svg/icon-service.ts b/libs/assets/src/svg/icon-service.ts deleted file mode 100644 index b397431da28..00000000000 --- a/libs/assets/src/svg/icon-service.ts +++ /dev/null @@ -1,25 +0,0 @@ -class Icon { - constructor(readonly svg: string) {} -} - -// We only export the type to prohibit the creation of Icons without using -// the `svgIcon` template literal tag. -export type { Icon }; - -export function isIcon(icon: unknown): icon is Icon { - return icon instanceof Icon; -} - -export class DynamicContentNotAllowedError extends Error { - constructor() { - super("Dynamic content in icons is not allowed due to risk of user-injected XSS."); - } -} - -export function svgIcon(strings: TemplateStringsArray, ...values: unknown[]): Icon { - if (values.length > 0) { - throw new DynamicContentNotAllowedError(); - } - - return new Icon(strings[0]); -} diff --git a/libs/assets/src/svg/index.ts b/libs/assets/src/svg/index.ts index 9f86a14f772..6a0fff490ff 100644 --- a/libs/assets/src/svg/index.ts +++ b/libs/assets/src/svg/index.ts @@ -1,2 +1,2 @@ export * from "./svgs"; -export * from "./icon-service"; +export * from "./svg"; diff --git a/libs/assets/src/svg/icon-service.spec.ts b/libs/assets/src/svg/svg.spec.ts similarity index 69% rename from libs/assets/src/svg/icon-service.spec.ts rename to libs/assets/src/svg/svg.spec.ts index 2561c85aefa..2d8401f0b5d 100644 --- a/libs/assets/src/svg/icon-service.spec.ts +++ b/libs/assets/src/svg/svg.spec.ts @@ -1,5 +1,5 @@ -import * as IconExports from "./icon-service"; -import { DynamicContentNotAllowedError, isIcon, svgIcon } from "./icon-service"; +import * as IconExports from "./svg"; +import { DynamicContentNotAllowedError, isBitSvg, svg } from "./svg"; describe("Icon", () => { it("exports should not expose Icon class", () => { @@ -8,13 +8,13 @@ describe("Icon", () => { describe("isIcon", () => { it("should return true when input is icon", () => { - const result = isIcon(svgIcon`icon`); + const result = isBitSvg(svg`icon`); expect(result).toBe(true); }); it("should return false when input is not an icon", () => { - const result = isIcon({ svg: "not an icon" }); + const result = isBitSvg({ svg: "not an icon" }); expect(result).toBe(false); }); @@ -24,13 +24,13 @@ describe("Icon", () => { it("should throw when attempting to create dynamic icons", () => { const dynamic = "some user input"; - const f = () => svgIcon`static and ${dynamic}`; + const f = () => svg`static and ${dynamic}`; expect(f).toThrow(DynamicContentNotAllowedError); }); it("should return svg content when supplying icon with svg string", () => { - const icon = svgIcon`safe static content`; + const icon = svg`safe static content`; expect(icon.svg).toBe("safe static content"); }); diff --git a/libs/assets/src/svg/svg.ts b/libs/assets/src/svg/svg.ts new file mode 100644 index 00000000000..71324ea4bac --- /dev/null +++ b/libs/assets/src/svg/svg.ts @@ -0,0 +1,25 @@ +class BitSvg { + constructor(readonly svg: string) {} +} + +// We only export the type to prohibit the creation of Svgs without using +// the `svg` template literal tag. +export type { BitSvg }; + +export function isBitSvg(svgContent: unknown): svgContent is BitSvg { + return svgContent instanceof BitSvg; +} + +export class DynamicContentNotAllowedError extends Error { + constructor() { + super("Dynamic content in icons is not allowed due to risk of user-injected XSS."); + } +} + +export function svg(strings: TemplateStringsArray, ...values: unknown[]): BitSvg { + if (values.length > 0) { + throw new DynamicContentNotAllowedError(); + } + + return new BitSvg(strings[0]); +} diff --git a/libs/assets/src/svg/svgs/account-warning.icon.ts b/libs/assets/src/svg/svgs/account-warning.icon.ts index 80e29dad870..81bf62d6e64 100644 --- a/libs/assets/src/svg/svgs/account-warning.icon.ts +++ b/libs/assets/src/svg/svgs/account-warning.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const AccountWarning = svgIcon` +export const AccountWarning = svg` diff --git a/libs/assets/src/svg/svgs/active-send.icon.ts b/libs/assets/src/svg/svgs/active-send.icon.ts index 3b12ee865d1..3016466e062 100644 --- a/libs/assets/src/svg/svgs/active-send.icon.ts +++ b/libs/assets/src/svg/svgs/active-send.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ActiveSendIcon = svgIcon` +export const ActiveSendIcon = svg` diff --git a/libs/assets/src/svg/svgs/admin-console.ts b/libs/assets/src/svg/svgs/admin-console.ts index 3e8f47ec4a5..146c834b442 100644 --- a/libs/assets/src/svg/svgs/admin-console.ts +++ b/libs/assets/src/svg/svgs/admin-console.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const AdminConsoleLogo = svgIcon` +const AdminConsoleLogo = svg` diff --git a/libs/assets/src/svg/svgs/auto-confirmation.ts b/libs/assets/src/svg/svgs/auto-confirmation.ts index 2a1416a5d25..5d0e0dd380c 100644 --- a/libs/assets/src/svg/svgs/auto-confirmation.ts +++ b/libs/assets/src/svg/svgs/auto-confirmation.ts @@ -1,5 +1,5 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const AutoConfirmSvg = svgIcon` +export const AutoConfirmSvg = svg` `; diff --git a/libs/assets/src/svg/svgs/background-left-illustration.ts b/libs/assets/src/svg/svgs/background-left-illustration.ts index a34f31f1621..f091f905c64 100644 --- a/libs/assets/src/svg/svgs/background-left-illustration.ts +++ b/libs/assets/src/svg/svgs/background-left-illustration.ts @@ -1,5 +1,5 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BackgroundLeftIllustration = svgIcon` +export const BackgroundLeftIllustration = svg` `; diff --git a/libs/assets/src/svg/svgs/background-right-illustration.ts b/libs/assets/src/svg/svgs/background-right-illustration.ts index 1c488f7242d..8f3bbba3462 100644 --- a/libs/assets/src/svg/svgs/background-right-illustration.ts +++ b/libs/assets/src/svg/svgs/background-right-illustration.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BackgroundRightIllustration = svgIcon` +export const BackgroundRightIllustration = svg` diff --git a/libs/assets/src/svg/svgs/bitwarden-icon.ts b/libs/assets/src/svg/svgs/bitwarden-icon.ts index 203460952b5..43aea78ced6 100644 --- a/libs/assets/src/svg/svgs/bitwarden-icon.ts +++ b/libs/assets/src/svg/svgs/bitwarden-icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BitwardenIcon = svgIcon` +export const BitwardenIcon = svg` diff --git a/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts b/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts index 9c1c7248ec6..85d0a471a6e 100644 --- a/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts +++ b/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BitwardenLogo = svgIcon` +export const BitwardenLogo = svg` Bitwarden diff --git a/libs/assets/src/svg/svgs/browser-extension.ts b/libs/assets/src/svg/svgs/browser-extension.ts index c15a536c007..2c40c584255 100644 --- a/libs/assets/src/svg/svgs/browser-extension.ts +++ b/libs/assets/src/svg/svgs/browser-extension.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BrowserExtensionIcon = svgIcon` +export const BrowserExtensionIcon = svg` diff --git a/libs/assets/src/svg/svgs/business-unit-portal.ts b/libs/assets/src/svg/svgs/business-unit-portal.ts index db3a6b8ef4f..cd06afcbf9a 100644 --- a/libs/assets/src/svg/svgs/business-unit-portal.ts +++ b/libs/assets/src/svg/svgs/business-unit-portal.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const BusinessUnitPortalLogo = svgIcon` +const BusinessUnitPortalLogo = svg` diff --git a/libs/assets/src/svg/svgs/business-welcome.icon.ts b/libs/assets/src/svg/svgs/business-welcome.icon.ts index 06c4950ec18..1d1caed8d47 100644 --- a/libs/assets/src/svg/svgs/business-welcome.icon.ts +++ b/libs/assets/src/svg/svgs/business-welcome.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BusinessWelcome = svgIcon` +export const BusinessWelcome = svg` diff --git a/libs/assets/src/svg/svgs/carousel-icon.ts b/libs/assets/src/svg/svgs/carousel-icon.ts index e29fd952098..4d645ad8029 100644 --- a/libs/assets/src/svg/svgs/carousel-icon.ts +++ b/libs/assets/src/svg/svgs/carousel-icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const CarouselIcon = svgIcon` +export const CarouselIcon = svg` diff --git a/libs/assets/src/svg/svgs/credit-card.icon.ts b/libs/assets/src/svg/svgs/credit-card.icon.ts index e334766fac7..dd0eb6a121a 100644 --- a/libs/assets/src/svg/svgs/credit-card.icon.ts +++ b/libs/assets/src/svg/svgs/credit-card.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const CreditCardIcon = svgIcon` +export const CreditCardIcon = svg` diff --git a/libs/assets/src/svg/svgs/deactivated-org.ts b/libs/assets/src/svg/svgs/deactivated-org.ts index 75b25e3fd27..d2566712a98 100644 --- a/libs/assets/src/svg/svgs/deactivated-org.ts +++ b/libs/assets/src/svg/svgs/deactivated-org.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DeactivatedOrg = svgIcon` +export const DeactivatedOrg = svg` diff --git a/libs/assets/src/svg/svgs/devices.icon.ts b/libs/assets/src/svg/svgs/devices.icon.ts index 7c97df48657..a3a4aa06442 100644 --- a/libs/assets/src/svg/svgs/devices.icon.ts +++ b/libs/assets/src/svg/svgs/devices.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DevicesIcon = svgIcon` +export const DevicesIcon = svg` diff --git a/libs/assets/src/svg/svgs/domain.icon.ts b/libs/assets/src/svg/svgs/domain.icon.ts index 04bd173be98..af47b1930d7 100644 --- a/libs/assets/src/svg/svgs/domain.icon.ts +++ b/libs/assets/src/svg/svgs/domain.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DomainIcon = svgIcon` +export const DomainIcon = svg` diff --git a/libs/assets/src/svg/svgs/empty-trash.ts b/libs/assets/src/svg/svgs/empty-trash.ts index d6c0043d880..da48bd69c3e 100644 --- a/libs/assets/src/svg/svgs/empty-trash.ts +++ b/libs/assets/src/svg/svgs/empty-trash.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const EmptyTrash = svgIcon` +export const EmptyTrash = svg` diff --git a/libs/assets/src/svg/svgs/favorites.icon.ts b/libs/assets/src/svg/svgs/favorites.icon.ts index 4725d0b0a7c..8777eaeef88 100644 --- a/libs/assets/src/svg/svgs/favorites.icon.ts +++ b/libs/assets/src/svg/svgs/favorites.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const FavoritesIcon = svgIcon` +export const FavoritesIcon = svg` diff --git a/libs/assets/src/svg/svgs/gear.ts b/libs/assets/src/svg/svgs/gear.ts index 261c6d262e1..c04dc8e1a17 100644 --- a/libs/assets/src/svg/svgs/gear.ts +++ b/libs/assets/src/svg/svgs/gear.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const GearIcon = svgIcon` +export const GearIcon = svg` diff --git a/libs/assets/src/svg/svgs/generator.ts b/libs/assets/src/svg/svgs/generator.ts index 52368ddc204..26b09f19455 100644 --- a/libs/assets/src/svg/svgs/generator.ts +++ b/libs/assets/src/svg/svgs/generator.ts @@ -1,12 +1,12 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const GeneratorInactive = svgIcon` +export const GeneratorInactive = svg` `; -export const GeneratorActive = svgIcon` +export const GeneratorActive = svg` diff --git a/libs/assets/src/svg/svgs/item-types.ts b/libs/assets/src/svg/svgs/item-types.ts index 50ed51bd018..b066df72b0d 100644 --- a/libs/assets/src/svg/svgs/item-types.ts +++ b/libs/assets/src/svg/svgs/item-types.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ItemTypes = svgIcon` +export const ItemTypes = svg` diff --git a/libs/assets/src/svg/svgs/lock.icon.ts b/libs/assets/src/svg/svgs/lock.icon.ts index 9d73ad6294c..f42630739f1 100644 --- a/libs/assets/src/svg/svgs/lock.icon.ts +++ b/libs/assets/src/svg/svgs/lock.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const LockIcon = svgIcon` +export const LockIcon = svg` diff --git a/libs/assets/src/svg/svgs/login-cards.ts b/libs/assets/src/svg/svgs/login-cards.ts index 3a43b1a0121..13c456a1658 100644 --- a/libs/assets/src/svg/svgs/login-cards.ts +++ b/libs/assets/src/svg/svgs/login-cards.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const LoginCards = svgIcon` +export const LoginCards = svg` diff --git a/libs/assets/src/svg/svgs/no-credentials.icon.ts b/libs/assets/src/svg/svgs/no-credentials.icon.ts index bfecfd4834c..da7795db808 100644 --- a/libs/assets/src/svg/svgs/no-credentials.icon.ts +++ b/libs/assets/src/svg/svgs/no-credentials.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoCredentialsIcon = svgIcon` +export const NoCredentialsIcon = svg` diff --git a/libs/assets/src/svg/svgs/no-folders.ts b/libs/assets/src/svg/svgs/no-folders.ts index c8858ca83e5..7facc01e4d6 100644 --- a/libs/assets/src/svg/svgs/no-folders.ts +++ b/libs/assets/src/svg/svgs/no-folders.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoFolders = svgIcon` +export const NoFolders = svg` diff --git a/libs/assets/src/svg/svgs/no-results.ts b/libs/assets/src/svg/svgs/no-results.ts index 5f914ad213c..75ad485181f 100644 --- a/libs/assets/src/svg/svgs/no-results.ts +++ b/libs/assets/src/svg/svgs/no-results.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoResults = svgIcon` +export const NoResults = svg` diff --git a/libs/assets/src/svg/svgs/no-send.icon.ts b/libs/assets/src/svg/svgs/no-send.icon.ts index a246c0177f8..a7125caabf6 100644 --- a/libs/assets/src/svg/svgs/no-send.icon.ts +++ b/libs/assets/src/svg/svgs/no-send.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoSendsIcon = svgIcon` +export const NoSendsIcon = svg` diff --git a/libs/assets/src/svg/svgs/party.ts b/libs/assets/src/svg/svgs/party.ts index efa5331f4fc..991f4a3deda 100644 --- a/libs/assets/src/svg/svgs/party.ts +++ b/libs/assets/src/svg/svgs/party.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const Party = svgIcon` +export const Party = svg` diff --git a/libs/assets/src/svg/svgs/password-manager.ts b/libs/assets/src/svg/svgs/password-manager.ts index 5b19562e022..aa7e8ecc52d 100644 --- a/libs/assets/src/svg/svgs/password-manager.ts +++ b/libs/assets/src/svg/svgs/password-manager.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const PasswordManagerLogo = svgIcon` +const PasswordManagerLogo = svg` diff --git a/libs/assets/src/svg/svgs/provider-portal.ts b/libs/assets/src/svg/svgs/provider-portal.ts index fad2ce6b864..97d23633a9e 100644 --- a/libs/assets/src/svg/svgs/provider-portal.ts +++ b/libs/assets/src/svg/svgs/provider-portal.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const ProviderPortalLogo = svgIcon` +const ProviderPortalLogo = svg` diff --git a/libs/assets/src/svg/svgs/registration-check-email.icon.ts b/libs/assets/src/svg/svgs/registration-check-email.icon.ts index ae4cf3098e6..006a60bc7c0 100644 --- a/libs/assets/src/svg/svgs/registration-check-email.icon.ts +++ b/libs/assets/src/svg/svgs/registration-check-email.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RegistrationCheckEmailIcon = svgIcon` +export const RegistrationCheckEmailIcon = svg` diff --git a/libs/assets/src/svg/svgs/registration-user-add.icon.ts b/libs/assets/src/svg/svgs/registration-user-add.icon.ts index 7428daa5848..358412c38eb 100644 --- a/libs/assets/src/svg/svgs/registration-user-add.icon.ts +++ b/libs/assets/src/svg/svgs/registration-user-add.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RegistrationUserAddIcon = svgIcon` +export const RegistrationUserAddIcon = svg` diff --git a/libs/assets/src/svg/svgs/report-breach.icon.ts b/libs/assets/src/svg/svgs/report-breach.icon.ts index 83dd6c72b82..e926388e333 100644 --- a/libs/assets/src/svg/svgs/report-breach.icon.ts +++ b/libs/assets/src/svg/svgs/report-breach.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportBreach = svgIcon` +export const ReportBreach = svg` diff --git a/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts b/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts index 0309eb643d9..590e7d7d1a1 100644 --- a/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts +++ b/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportExposedPasswords = svgIcon` +export const ReportExposedPasswords = svg` diff --git a/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts b/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts index 487381ccaa9..831a6570812 100644 --- a/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts +++ b/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportUnsecuredWebsites = svgIcon` +export const ReportUnsecuredWebsites = svg` diff --git a/libs/assets/src/svg/svgs/restricted-view.ts b/libs/assets/src/svg/svgs/restricted-view.ts index 5eec1a4a972..7bf40467ac6 100644 --- a/libs/assets/src/svg/svgs/restricted-view.ts +++ b/libs/assets/src/svg/svgs/restricted-view.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RestrictedView = svgIcon` +export const RestrictedView = svg` diff --git a/libs/assets/src/svg/svgs/secrets-manager-alt.ts b/libs/assets/src/svg/svgs/secrets-manager-alt.ts index 98640803ca9..70fa7d6386c 100644 --- a/libs/assets/src/svg/svgs/secrets-manager-alt.ts +++ b/libs/assets/src/svg/svgs/secrets-manager-alt.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SecretsManagerAlt = svgIcon` +export const SecretsManagerAlt = svg` diff --git a/libs/assets/src/svg/svgs/secrets-manager.ts b/libs/assets/src/svg/svgs/secrets-manager.ts index 62b54174c55..3cd66df59e3 100644 --- a/libs/assets/src/svg/svgs/secrets-manager.ts +++ b/libs/assets/src/svg/svgs/secrets-manager.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const SecretsManagerLogo = svgIcon` +const SecretsManagerLogo = svg` diff --git a/libs/assets/src/svg/svgs/security.ts b/libs/assets/src/svg/svgs/security.ts index 6e475b25ab7..119d0164599 100644 --- a/libs/assets/src/svg/svgs/security.ts +++ b/libs/assets/src/svg/svgs/security.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const Security = svgIcon` +export const Security = svg` diff --git a/libs/assets/src/svg/svgs/send.ts b/libs/assets/src/svg/svgs/send.ts index f09f59a5388..309844f9fd9 100644 --- a/libs/assets/src/svg/svgs/send.ts +++ b/libs/assets/src/svg/svgs/send.ts @@ -1,12 +1,12 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SendInactive = svgIcon` +export const SendInactive = svg` `; -export const SendActive = svgIcon` +export const SendActive = svg` diff --git a/libs/assets/src/svg/svgs/settings.ts b/libs/assets/src/svg/svgs/settings.ts index 3b54bbbd88c..b0e42821c6b 100644 --- a/libs/assets/src/svg/svgs/settings.ts +++ b/libs/assets/src/svg/svgs/settings.ts @@ -1,13 +1,13 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SettingsInactive = svgIcon` +export const SettingsInactive = svg` `; -export const SettingsActive = svgIcon` +export const SettingsActive = svg` diff --git a/libs/assets/src/svg/svgs/shield.ts b/libs/assets/src/svg/svgs/shield.ts index af626a98e9d..bd5f9e02d1d 100644 --- a/libs/assets/src/svg/svgs/shield.ts +++ b/libs/assets/src/svg/svgs/shield.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const BitwardenShield = svgIcon` +const BitwardenShield = svg` diff --git a/libs/assets/src/svg/svgs/sso-key.icon.ts b/libs/assets/src/svg/svgs/sso-key.icon.ts index ad81c707449..d6e45b13b42 100644 --- a/libs/assets/src/svg/svgs/sso-key.icon.ts +++ b/libs/assets/src/svg/svgs/sso-key.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SsoKeyIcon = svgIcon` +export const SsoKeyIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts index 622875b59f2..11d2fafb745 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthAuthenticatorIcon = svgIcon` +export const TwoFactorAuthAuthenticatorIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts index 5bf43334d18..a40a6418885 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts @@ -1,8 +1,10 @@ -// this svg includes the Duo logo, which contains colors not part of our bitwarden theme colors /* eslint-disable @bitwarden/components/require-theme-colors-in-svg */ -import { svgIcon } from "../icon-service"; -export const TwoFactorAuthDuoIcon = svgIcon` +// this svg includes the Duo logo, which contains colors not part of our bitwarden theme colors + +import { svg } from "../svg"; + +export const TwoFactorAuthDuoIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts index 20709a8a1e1..8fdee85da82 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthEmailIcon = svgIcon` +export const TwoFactorAuthEmailIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts index 0e467bf1901..3eab3bb00c6 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthSecurityKeyFailedIcon = svgIcon` +export const TwoFactorAuthSecurityKeyFailedIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts index f10068b735b..830db83f3e8 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthSecurityKeyIcon = svgIcon` +export const TwoFactorAuthSecurityKeyIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts index b9114259584..9f0decb1f36 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthWebAuthnIcon = svgIcon` +export const TwoFactorAuthWebAuthnIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts index d4d38c363ae..6368442cde6 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts @@ -1,8 +1,9 @@ -// this svg includes the Yubico logo, which contains colors not part of our bitwarden theme colors /* eslint-disable @bitwarden/components/require-theme-colors-in-svg */ -import { svgIcon } from "../icon-service"; +// this svg includes the Yubico logo, which contains colors not part of our bitwarden theme colors -export const TwoFactorAuthYubicoIcon = svgIcon` +import { svg } from "../svg"; + +export const TwoFactorAuthYubicoIcon = svg` diff --git a/libs/assets/src/svg/svgs/unlocked.icon.ts b/libs/assets/src/svg/svgs/unlocked.icon.ts index 6ce40819e44..1a754733d26 100644 --- a/libs/assets/src/svg/svgs/unlocked.icon.ts +++ b/libs/assets/src/svg/svgs/unlocked.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UnlockedIcon = svgIcon` +export const UnlockedIcon = svg` diff --git a/libs/assets/src/svg/svgs/user-lock.icon.ts b/libs/assets/src/svg/svgs/user-lock.icon.ts index cc848a05769..5deead382b3 100644 --- a/libs/assets/src/svg/svgs/user-lock.icon.ts +++ b/libs/assets/src/svg/svgs/user-lock.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UserLockIcon = svgIcon` +export const UserLockIcon = svg` diff --git a/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts b/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts index 19e1aa3e6cd..c175bb78993 100644 --- a/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts +++ b/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UserVerificationBiometricsIcon = svgIcon` +export const UserVerificationBiometricsIcon = svg` diff --git a/libs/assets/src/svg/svgs/vault-open.ts b/libs/assets/src/svg/svgs/vault-open.ts index 3ad82b9bbac..52e8a971d60 100644 --- a/libs/assets/src/svg/svgs/vault-open.ts +++ b/libs/assets/src/svg/svgs/vault-open.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultOpen = svgIcon` +export const VaultOpen = svg` diff --git a/libs/assets/src/svg/svgs/vault.icon.ts b/libs/assets/src/svg/svgs/vault.icon.ts index 61ec2589b34..1f442ad0471 100644 --- a/libs/assets/src/svg/svgs/vault.icon.ts +++ b/libs/assets/src/svg/svgs/vault.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultIcon = svgIcon` +export const VaultIcon = svg` diff --git a/libs/assets/src/svg/svgs/vault.ts b/libs/assets/src/svg/svgs/vault.ts index 1c699f2ba8e..8e1acab2670 100644 --- a/libs/assets/src/svg/svgs/vault.ts +++ b/libs/assets/src/svg/svgs/vault.ts @@ -1,13 +1,13 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultInactive = svgIcon` +export const VaultInactive = svg` `; -export const VaultActive = svgIcon` +export const VaultActive = svg` diff --git a/libs/assets/src/svg/svgs/wave.icon.ts b/libs/assets/src/svg/svgs/wave.icon.ts index 6c97d0fbbb3..7b00ba0f3eb 100644 --- a/libs/assets/src/svg/svgs/wave.icon.ts +++ b/libs/assets/src/svg/svgs/wave.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const WaveIcon = svgIcon` +export const WaveIcon = svg` diff --git a/libs/auth/src/angular/input-password/input-password.component.ts b/libs/auth/src/angular/input-password/input-password.component.ts index 62294f037a0..b81e01156f1 100644 --- a/libs/auth/src/angular/input-password/input-password.component.ts +++ b/libs/auth/src/angular/input-password/input-password.component.ts @@ -10,7 +10,9 @@ import { import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -209,6 +211,7 @@ export class InputPasswordComponent implements OnInit { constructor( private auditService: AuditService, private cipherService: CipherService, + private configService: ConfigService, private dialogService: DialogService, private formBuilder: FormBuilder, private i18nService: I18nService, @@ -312,7 +315,7 @@ export class InputPasswordComponent implements OnInit { } if (!this.email) { - throw new Error("Email is required to create master key."); + throw new Error("Email not found."); } // 1. Determine kdfConfig @@ -320,13 +323,13 @@ export class InputPasswordComponent implements OnInit { this.kdfConfig = DEFAULT_KDF_CONFIG; } else { if (!this.userId) { - throw new Error("userId not passed down"); + throw new Error("userId not found."); } this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId)); } if (this.kdfConfig == null) { - throw new Error("KdfConfig is required to create master key."); + throw new Error("KdfConfig not found."); } const salt = @@ -334,7 +337,7 @@ export class InputPasswordComponent implements OnInit { ? await firstValueFrom(this.masterPasswordService.saltForUser$(this.userId)) : this.masterPasswordService.emailToSalt(this.email); if (salt == null) { - throw new Error("Salt is required to create master key."); + throw new Error("Salt not found."); } // 2. Verify current password is correct (if necessary) @@ -361,6 +364,41 @@ export class InputPasswordComponent implements OnInit { return; } + // When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used. + const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword, + ); + + if (newApisWithInputPasswordFlagEnabled) { + // 4. Build a PasswordInputResult object + const passwordInputResult: PasswordInputResult = { + newPassword, + kdfConfig: this.kdfConfig, + salt, + newPasswordHint, + newApisWithInputPasswordFlagEnabled, // To be removed in PM-28143 + }; + + if ( + this.flow === InputPasswordFlow.ChangePassword || + this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation + ) { + passwordInputResult.currentPassword = currentPassword; + } + + if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) { + passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value; + } + + // 5. Emit and return PasswordInputResult object + this.onPasswordFormSubmit.emit(passwordInputResult); + return passwordInputResult; + } + + /******************************************************************* + * The following code (within this `try`) to be removed in PM-28143 + *******************************************************************/ + // 4. Create cryptographic keys and build a PasswordInputResult object const newMasterKey = await this.keyService.makeMasterKey( newPassword, diff --git a/libs/auth/src/angular/input-password/input-password.mdx b/libs/auth/src/angular/input-password/input-password.mdx index e3cdcbf08b9..4b174658d16 100644 --- a/libs/auth/src/angular/input-password/input-password.mdx +++ b/libs/auth/src/angular/input-password/input-password.mdx @@ -6,14 +6,12 @@ import * as stories from "./input-password.stories.ts"; # InputPassword Component -The `InputPasswordComponent` allows a user to enter master password related credentials. -Specifically, it does the following: +The `InputPasswordComponent` allows a user to enter a new master password for the purpose of setting +an initial password or changing an existing password. Specifically, it does the following: 1. Displays form fields in the UI 2. Validates form fields -3. Generates cryptographic properties based on the form inputs (e.g. `newMasterKey`, - `newServerMasterKeyHash`, etc.) -4. Emits the generated properties to the parent component +3. Emits values to the parent component The `InputPasswordComponent` is central to our set/change password flows, allowing us to keep our form UI and validation logic consistent. As such, it is intended for re-use in different set/change @@ -30,7 +28,6 @@ those values as needed. - [The InputPasswordFlow](#the-inputpasswordflow) - [Use Cases](#use-cases) - [HTML - Form Fields](#html---form-fields) - - [TypeScript - Credential Generation](#typescript---credential-generation) - [Difference between SetInitialPasswordAccountRegistration and SetInitialPasswordAuthedUser](#difference-between-setinitialpasswordaccountregistration-and-setinitialpasswordautheduser) - [Validation](#validation) - [Submit Logic](#submit-logic) @@ -44,20 +41,20 @@ those values as needed. **Required** - `flow` - the parent component must provide an `InputPasswordFlow`, which is used to determine - which form input elements will be displayed in the UI and which cryptographic keys will be created - and emitted. [Click here](#the-inputpasswordflow) to learn more about the different - `InputPasswordFlow` options. + which form input elements will be displayed in the UI and which values will be emitted. + [Click here](#the-inputpasswordflow) to learn more about the different `InputPasswordFlow` + options. **Optional (sometimes)** -These two `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` -are not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that -the `email` and/or `userId` is present in certain flows, while not present in other flows. +These `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` are +not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that the +`email` and/or `userId` is present in certain flows, while not present in other flows. -- `email` - allows the `InputPasswordComponent` to generate a master key +- `email` - allows the `InputPasswordComponent` to use the email as a salt (if needed) - `userId` - allows the `InputPasswordComponent` to do things like get the user's `kdfConfig`, - verify that a current password is correct, and perform validation prior to user key rotation on - the parent + verify that a current password is correct, and perform validation prior to user key rotation (if + selected) on the parent **Optional** @@ -87,8 +84,7 @@ These `@Inputs` are truly optional. ## The `InputPasswordFlow` The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the -credential generation logic of the component. It is important for the dev to understand when to use -each flow. +logic of the component. It is important for the dev to understand when to use each flow. ### Use Cases @@ -106,8 +102,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti - A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set their initial password -- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a - starting role that requires them to have/set their initial password +- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with the reset + password permission ("manage account recovery") from the start, which requires them to have/set + their initial password - A note on JIT provisioned user flows: - Even though a JIT provisioned user is a brand-new user who was “just” created, we consider them to be an “existing authed user” _from the perspective of the set-password flow_. This is @@ -117,8 +114,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set their initial password, their account does not yet exist in the database, and will only be created once they set an initial password. -- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now - requires them to have/set their initial password +- An existing user in a TDE org logs in after an org admin upgraded the user to have the reset + password persmission ("manage account recovery"), which now requires the user to have/set their + initial password - An existing user logs in after their org admin offboarded the org from TDE, and the user must now have/set their initial password

@@ -126,7 +124,7 @@ Used in scenarios where we do have an existing and authed user, and thus an acti Used in scenarios where we simply want to offer the user the ability to change their password: -- User clicks an org email invite link an logs in with their password which does not meet the org's +- User clicks an org email invite link and logs in with their password which does not meet the org's policy requirements - User logs in with password that does not meet the org's policy requirements - User logs in after their password was reset via Account Recovery (and now they must change their @@ -156,26 +154,10 @@ which form field UI elements get displayed.
-### TypeScript - Credential Generation - -- **`SetInitialPasswordAccountRegistration`** and **`SetInitialPasswordAuthedUser`** - - These flows involve a user setting their password for the first time. Therefore on submit the - component will only generate new credentials (`newMasterKey`) and not current credentials - (`currentMasterKey`).

-- **`ChangePassword`** and **`ChangePasswordWithOptionalUserKeyRotation`** - - These flows both require the user to enter a current password along with a new password. - Therefore on submit the component will generate current credentials (`currentMasterKey`) along - with new credentials (`newMasterKey`).

-- **`ChangePasswordDelegation`** - - This flow does not generate any credentials, but simply validates the new password and emits it - up to the parent. - -
- ### Difference between `SetInitialPasswordAccountRegistration` and `SetInitialPasswordAuthedUser` -These two flows are similar in that they display the same form fields and only generate new -credentials, but we need to keep them separate for the following reasons: +These two flows are similar in that they display the same form fields, but we need to keep them +separate for the following reasons: - `SetInitialPasswordAccountRegistration` involves scenarios where we have no existing user, and **thus NO active account `userId`**: @@ -183,7 +165,7 @@ credentials, but we need to keep them separate for the following reasons: and **thus an active account `userId`**: The presence or absence of an active account `userId` is important because it determines how we get -the correct `kdfConfig` prior to key generation: +the correct `kdfConfig`: - If there is no `userId` passed down from the parent, we default to `DEFAULT_KDF_CONFIG` - If there is a `userId` passed down from the parent, we get the `kdfConfig` from state using the @@ -223,25 +205,16 @@ When the form is submitted, the `InputPasswordComponent` does the following in o checkbox) - Checks that the new password adheres to any enforced master password policies that were optionally passed down by the parent -2. Uses the form inputs to create cryptographic properties (`newMasterKey`, - `newServerMasterKeyHash`, etc.) -3. Emits those cryptographic properties up to the parent (along with other values defined in - `PasswordInputResult`) to be used by the parent as needed. +2. Emits values up to the parent (along with other values defined in `PasswordInputResult`) to be + used by the parent as needed. ```typescript export interface PasswordInputResult { currentPassword?: string; - currentMasterKey?: MasterKey; - currentServerMasterKeyHash?: string; - currentLocalMasterKeyHash?: string; - newPassword: string; - newPasswordHint?: string; - newMasterKey?: MasterKey; - newServerMasterKeyHash?: string; - newLocalMasterKeyHash?: string; - kdfConfig?: KdfConfig; + salt?: MasterPasswordSalt; + newPasswordHint?: string; rotateUserKey?: boolean; } ``` diff --git a/libs/auth/src/angular/input-password/input-password.stories.ts b/libs/auth/src/angular/input-password/input-password.stories.ts index 285ce94b269..9e3a6419d2a 100644 --- a/libs/auth/src/angular/input-password/input-password.stories.ts +++ b/libs/auth/src/angular/input-password/input-password.stories.ts @@ -10,6 +10,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; @@ -59,6 +60,13 @@ export default { getAllDecrypted: () => Promise.resolve([]), }, }, + // Can remove ConfigService from component and stories in PM-28143 (if it is no longer used) + { + provide: ConfigService, + useValue: { + getFeatureFlag: () => false, // default to false since flag does not effect UI + }, + }, { provide: KdfConfigService, useValue: { diff --git a/libs/auth/src/angular/input-password/password-input-result.ts b/libs/auth/src/angular/input-password/password-input-result.ts index 11c8f0d274d..575302a9ee0 100644 --- a/libs/auth/src/angular/input-password/password-input-result.ts +++ b/libs/auth/src/angular/input-password/password-input-result.ts @@ -10,6 +10,20 @@ export interface PasswordInputResult { newPasswordHint?: string; rotateUserKey?: boolean; + /** + * Temporary property that persists the flag state through the entire set/change password process. + * This allows flows to consume this value instead of re-checking the flag state via ConfigService themselves. + * + * The ChangePasswordDelegation flows (Emergency Access Takeover and Account Recovery), however, only ever + * require a raw newPassword from the InputPasswordComponent regardless of whether the flag is on or off. + * Flagging for those 2 flows will be done via the ConfigService in their respective services. + * + * To be removed in PM-28143 + */ + newApisWithInputPasswordFlagEnabled?: boolean; + + // The deprecated properties below will be removed in PM-28143: https://bitwarden.atlassian.net/browse/PM-28143 + /** @deprecated This low-level cryptographic state will be removed. It will be replaced by high level calls to masterpassword service, in the consumers of this interface. */ currentMasterKey?: MasterKey; /** @deprecated */ diff --git a/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts b/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts index e7a3e99759c..87b5173a6a7 100644 --- a/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts +++ b/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts @@ -9,7 +9,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { TwoFactorTimeoutIcon } from "@bitwarden/assets/svg"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { ButtonModule, IconModule } from "@bitwarden/components"; +import { ButtonModule, SvgModule } from "@bitwarden/components"; /** * RegistrationLinkExpiredComponentData @@ -24,7 +24,7 @@ export interface RegistrationLinkExpiredComponentData { @Component({ selector: "auth-registration-link-expired", templateUrl: "./registration-link-expired.component.html", - imports: [CommonModule, JslibModule, RouterModule, IconModule, ButtonModule], + imports: [CommonModule, JslibModule, RouterModule, SvgModule, ButtonModule], }) export class RegistrationLinkExpiredComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts index 714f6d49342..1161af836b4 100644 --- a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts +++ b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts @@ -20,7 +20,7 @@ import { ButtonModule, CheckboxModule, FormFieldModule, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -54,7 +54,7 @@ const DEFAULT_MARKETING_EMAILS_PREF_BY_REGION: Record = { CheckboxModule, ButtonModule, LinkModule, - IconModule, + SvgModule, RegistrationEnvSelectorComponent, ], }) diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html index 277ba047add..bf9482c7987 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html +++ b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html @@ -11,30 +11,30 @@ [ngSwitch]="provider.type" class="tw-w-16 md:tw-w-20 tw-mr-2 sm:tw-mr-4" > - - + - + - + - + - + + [content]="Icons.TwoFactorAuthWebAuthnIcon" + >
{{ provider.name }} {{ provider.description }} diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts index d8b2ab2508b..53ae509f182 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts @@ -18,7 +18,7 @@ import { ButtonModule, DialogModule, DialogService, - IconModule, + SvgModule, ItemModule, TypographyModule, } from "@bitwarden/components"; @@ -39,7 +39,7 @@ export type TwoFactorOptionsDialogResult = { ButtonModule, TypographyModule, ItemModule, - IconModule, + SvgModule, ], providers: [], }) diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.html b/libs/auth/src/angular/user-verification/user-verification-form-input.component.html index 5699f3dd9a4..8e8f41c394d 100644 --- a/libs/auth/src/angular/user-verification/user-verification-form-input.component.html +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.html @@ -42,7 +42,7 @@ >
- +

{{ "verifyWithBiometrics" | i18n }}

diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts index 296359c92ff..af73cc3de99 100644 --- a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts @@ -28,7 +28,7 @@ import { CalloutModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -64,7 +64,7 @@ import { ActiveClientVerificationOption } from "./active-client-verification-opt FormFieldModule, AsyncActionsModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, ButtonModule, CalloutModule, diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index 2ae79f46d7c..94d2c6b65aa 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -175,6 +175,8 @@ describe("WebAuthnLoginStrategy", () => { WebAuthnPrfOption: { EncryptedPrivateKey: mockEncPrfPrivateKey, EncryptedUserKey: mockEncUserKey, + CredentialId: "mockCredentialId", + Transports: ["usb", "nfc"], }, }; diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts index 77a881b5964..019e1d9860e 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -73,14 +73,15 @@ export class WebAuthnLoginStrategy extends LoginStrategy { const userDecryptionOptions = idTokenResponse?.userDecryptionOptions; if (userDecryptionOptions?.webAuthnPrfOption) { - const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption; - const credentials = this.cache.value.credentials; + // confirm we still have the prf key if (!credentials.prfKey) { return; } + const webAuthnPrfOption = userDecryptionOptions.webAuthnPrfOption; + // decrypt prf encrypted private key const privateKey = await this.encryptService.unwrapDecapsulationKey( webAuthnPrfOption.encryptedPrivateKey, diff --git a/libs/auth/src/common/models/domain/user-decryption-options.ts b/libs/auth/src/common/models/domain/user-decryption-options.ts index 44d8bff4d2c..561a833f3c9 100644 --- a/libs/auth/src/common/models/domain/user-decryption-options.ts +++ b/libs/auth/src/common/models/domain/user-decryption-options.ts @@ -5,6 +5,7 @@ import { Jsonify } from "type-fest"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { KeyConnectorUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response"; import { TrustedDeviceUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response"; +import { WebAuthnPrfDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response"; /** * Key Connector decryption options. Intended to be sent to the client for use after authentication. @@ -45,6 +46,61 @@ export class KeyConnectorUserDecryptionOption { } } +/** + * Trusted device decryption options. Intended to be sent to the client for use after authentication. + * @see {@link UserDecryptionOptions} + */ +/** + * WebAuthn PRF decryption options. Intended to be sent to the client for use after authentication. + * @see {@link UserDecryptionOptions} + */ +export class WebAuthnPrfUserDecryptionOption { + /** The encrypted private key that can be decrypted with the PRF key. */ + encryptedPrivateKey: string; + /** The encrypted user key that can be decrypted with the private key. */ + encryptedUserKey: string; + /** The credential ID for this WebAuthn PRF credential. */ + credentialId: string; + /** The transports supported by this credential. */ + transports: string[]; + + /** + * Initializes a new instance of the WebAuthnPrfUserDecryptionOption from a response object. + * @param response The WebAuthn PRF user decryption option response object. + * @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `response` is nullish. + */ + static fromResponse( + response: WebAuthnPrfDecryptionOptionResponse, + ): WebAuthnPrfUserDecryptionOption | undefined { + if (response == null) { + return undefined; + } + if (!response.encryptedPrivateKey || !response.encryptedUserKey) { + return undefined; + } + const options = new WebAuthnPrfUserDecryptionOption(); + options.encryptedPrivateKey = response.encryptedPrivateKey.encryptedString; + options.encryptedUserKey = response.encryptedUserKey.encryptedString; + options.credentialId = response.credentialId; + options.transports = response.transports || []; + return options; + } + + /** + * Initializes a new instance of a WebAuthnPrfUserDecryptionOption from a JSON object. + * @param obj JSON object to deserialize. + * @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `obj` is nullish. + */ + static fromJSON( + obj: Jsonify, + ): WebAuthnPrfUserDecryptionOption | undefined { + if (obj == null) { + return undefined; + } + return Object.assign(new WebAuthnPrfUserDecryptionOption(), obj); + } +} + /** * Trusted device decryption options. Intended to be sent to the client for use after authentication. * @see {@link UserDecryptionOptions} @@ -104,6 +160,8 @@ export class UserDecryptionOptions { trustedDeviceOption?: TrustedDeviceUserDecryptionOption; /** {@link KeyConnectorUserDecryptionOption} */ keyConnectorOption?: KeyConnectorUserDecryptionOption; + /** Array of {@link WebAuthnPrfUserDecryptionOption} */ + webAuthnPrfOptions?: WebAuthnPrfUserDecryptionOption[]; /** * Initializes a new instance of the UserDecryptionOptions from a response object. @@ -134,6 +192,18 @@ export class UserDecryptionOptions { decryptionOptions.keyConnectorOption = KeyConnectorUserDecryptionOption.fromResponse( responseOptions.keyConnectorOption, ); + + // The IdTokenResponse only returns a single WebAuthn PRF option to support immediate unlock after logging in + // with the same PRF passkey. + // Since our domain model supports multiple WebAuthn PRF options, we convert the single option into an array. + if (responseOptions.webAuthnPrfOption) { + const option = WebAuthnPrfUserDecryptionOption.fromResponse( + responseOptions.webAuthnPrfOption, + ); + if (option) { + decryptionOptions.webAuthnPrfOptions = [option]; + } + } } else { throw new Error( "User Decryption Options are required for client initialization. userDecryptionOptions is missing in response.", @@ -158,6 +228,12 @@ export class UserDecryptionOptions { obj?.keyConnectorOption, ); + if (obj?.webAuthnPrfOptions && Array.isArray(obj.webAuthnPrfOptions)) { + decryptionOptions.webAuthnPrfOptions = obj.webAuthnPrfOptions + .map((option) => WebAuthnPrfUserDecryptionOption.fromJSON(option)) + .filter((option) => option !== undefined); + } + return decryptionOptions; } } diff --git a/libs/common/src/admin-console/abstractions/policy/policy-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/policy/policy-api.service.abstraction.ts index 79055d3cb11..a044aac9d72 100644 --- a/libs/common/src/admin-console/abstractions/policy/policy-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/policy/policy-api.service.abstraction.ts @@ -3,10 +3,11 @@ import { PolicyType } from "../../enums"; import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options"; import { Policy } from "../../models/domain/policy"; import { PolicyRequest } from "../../models/request/policy.request"; +import { PolicyStatusResponse } from "../../models/response/policy-status.response"; import { PolicyResponse } from "../../models/response/policy.response"; export abstract class PolicyApiServiceAbstraction { - abstract getPolicy: (organizationId: string, type: PolicyType) => Promise; + abstract getPolicy: (organizationId: string, type: PolicyType) => Promise; abstract getPolicies: (organizationId: string) => Promise>; abstract getPoliciesByToken: ( diff --git a/libs/common/src/admin-console/models/response/policy-status.response.ts b/libs/common/src/admin-console/models/response/policy-status.response.ts new file mode 100644 index 00000000000..7e4ff604dd6 --- /dev/null +++ b/libs/common/src/admin-console/models/response/policy-status.response.ts @@ -0,0 +1,19 @@ +import { BaseResponse } from "../../../models/response/base.response"; +import { PolicyType } from "../../enums"; + +export class PolicyStatusResponse extends BaseResponse { + organizationId: string; + type: PolicyType; + data: any; + enabled: boolean; + canToggleState: boolean; + + constructor(response: any) { + super(response); + this.organizationId = this.getResponseProperty("OrganizationId"); + this.type = this.getResponseProperty("Type"); + this.data = this.getResponseProperty("Data"); + this.enabled = this.getResponseProperty("Enabled"); + this.canToggleState = this.getResponseProperty("CanToggleState") ?? true; + } +} diff --git a/libs/common/src/admin-console/services/policy/policy-api.service.ts b/libs/common/src/admin-console/services/policy/policy-api.service.ts index c0a5c74f1e3..dbf12e98860 100644 --- a/libs/common/src/admin-console/services/policy/policy-api.service.ts +++ b/libs/common/src/admin-console/services/policy/policy-api.service.ts @@ -14,6 +14,7 @@ import { PolicyData } from "../../models/data/policy.data"; import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options"; import { Policy } from "../../models/domain/policy"; import { PolicyRequest } from "../../models/request/policy.request"; +import { PolicyStatusResponse } from "../../models/response/policy-status.response"; import { PolicyResponse } from "../../models/response/policy.response"; export class PolicyApiService implements PolicyApiServiceAbstraction { @@ -23,7 +24,7 @@ export class PolicyApiService implements PolicyApiServiceAbstraction { private accountService: AccountService, ) {} - async getPolicy(organizationId: string, type: PolicyType): Promise { + async getPolicy(organizationId: string, type: PolicyType): Promise { const r = await this.apiService.send( "GET", "/organizations/" + organizationId + "/policies/" + type, diff --git a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts index 4ebadc0daa9..4c5a67d2c31 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts @@ -27,6 +27,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse { masterPasswordUnlock?: MasterPasswordUnlockResponse; trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse; keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse; + /** + * The IdTokenresponse only returns a single WebAuthn PRF option. + * To support immediate unlock after logging in with the same PRF passkey. + */ webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse; constructor(response: IUserDecryptionOptionsServerResponse) { diff --git a/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts b/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts index 478f6d88b5b..b2b5a57ce8f 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts @@ -6,19 +6,30 @@ import { BaseResponse } from "../../../../models/response/base.response"; export interface IWebAuthnPrfDecryptionOptionServerResponse { EncryptedPrivateKey: string; EncryptedUserKey: string; + CredentialId: string; + Transports: string[]; } export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse { encryptedPrivateKey: EncString; encryptedUserKey: EncString; + credentialId: string; + transports: string[]; constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) { super(response); - if (response.EncryptedPrivateKey) { - this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey")); + + const encPrivateKey = this.getResponseProperty("EncryptedPrivateKey"); + if (encPrivateKey) { + this.encryptedPrivateKey = new EncString(encPrivateKey); } - if (response.EncryptedUserKey) { - this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey")); + + const encUserKey = this.getResponseProperty("EncryptedUserKey"); + if (encUserKey) { + this.encryptedUserKey = new EncString(encUserKey); } + + this.credentialId = this.getResponseProperty("CredentialId"); + this.transports = this.getResponseProperty("Transports") || []; } } diff --git a/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts b/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts index 8db0532911f..1145abc2a76 100644 --- a/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts +++ b/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts @@ -64,14 +64,13 @@ describe("SendTokenService", () => { "send_id_required", "password_hash_b64_required", "email_required", - "email_and_otp_required_otp_sent", + "email_and_otp_required", "unknown", ]; const INVALID_GRANT_CODES: SendAccessTokenInvalidGrantError[] = [ "send_id_invalid", "password_hash_b64_invalid", - "email_invalid", "otp_invalid", "otp_generation_failed", "unknown", diff --git a/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts b/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts index befb869a89e..e9c7e80406e 100644 --- a/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts +++ b/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts @@ -31,13 +31,6 @@ export function passwordHashB64Invalid( return e.error === "invalid_grant" && e.send_access_error_type === "password_hash_b64_invalid"; } -export type EmailInvalid = InvalidGrant & { - send_access_error_type: "email_invalid"; -}; -export function emailInvalid(e: SendAccessTokenApiErrorResponse): e is EmailInvalid { - return e.error === "invalid_grant" && e.send_access_error_type === "email_invalid"; -} - export type OtpInvalid = InvalidGrant & { send_access_error_type: "otp_invalid"; }; diff --git a/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts b/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts index 57a70e62586..3e76a8f61f6 100644 --- a/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts +++ b/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts @@ -39,16 +39,12 @@ export function emailRequired(e: SendAccessTokenApiErrorResponse): e is EmailReq return e.error === "invalid_request" && e.send_access_error_type === "email_required"; } -export type EmailAndOtpRequiredEmailSent = InvalidRequest & { - send_access_error_type: "email_and_otp_required_otp_sent"; +export type EmailAndOtpRequired = InvalidRequest & { + send_access_error_type: "email_and_otp_required"; }; -export function emailAndOtpRequiredEmailSent( - e: SendAccessTokenApiErrorResponse, -): e is EmailAndOtpRequiredEmailSent { - return ( - e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required_otp_sent" - ); +export function emailAndOtpRequired(e: SendAccessTokenApiErrorResponse): e is EmailAndOtpRequired { + return e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required"; } export type UnknownInvalidRequest = InvalidRequest & { diff --git a/libs/common/src/autofill/constants/index.ts b/libs/common/src/autofill/constants/index.ts index dc79e27b6aa..f3f0077a37f 100644 --- a/libs/common/src/autofill/constants/index.ts +++ b/libs/common/src/autofill/constants/index.ts @@ -28,6 +28,41 @@ export const EVENTS = { SUBMIT: "submit", } as const; +/** + * HTML attributes observed by the MutationObserver for autofill form/field tracking. + * If you need to observe a new attribute, add it here. + */ +export const AUTOFILL_ATTRIBUTES = { + ACTION: "action", + ARIA_DESCRIBEDBY: "aria-describedby", + ARIA_DISABLED: "aria-disabled", + ARIA_HASPOPUP: "aria-haspopup", + ARIA_HIDDEN: "aria-hidden", + ARIA_LABEL: "aria-label", + ARIA_LABELLEDBY: "aria-labelledby", + AUTOCOMPLETE: "autocomplete", + AUTOCOMPLETE_TYPE: "autocompletetype", + X_AUTOCOMPLETE_TYPE: "x-autocompletetype", + CHECKED: "checked", + CLASS: "class", + DATA_LABEL: "data-label", + DATA_STRIPE: "data-stripe", + DISABLED: "disabled", + ID: "id", + MAXLENGTH: "maxlength", + METHOD: "method", + NAME: "name", + PLACEHOLDER: "placeholder", + POPOVER: "popover", + POPOVERTARGET: "popovertarget", + POPOVERTARGETACTION: "popovertargetaction", + READONLY: "readonly", + REL: "rel", + TABINDEX: "tabindex", + TITLE: "title", + TYPE: "type", +} as const; + export const ClearClipboardDelay = { Never: null as null, TenSeconds: 10, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index c96f6996078..9941e7671f4 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -13,13 +13,16 @@ export enum FeatureFlag { /* Admin Console Team */ AutoConfirm = "pm-19934-auto-confirm-organization-users", BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration", - IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud", + DefaultUserCollectionRestore = "pm-30883-my-items-restored-users", MembersComponentRefactor = "pm-29503-refactor-members-inheritance", /* Auth */ PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin", + PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password", + SafariAccountSwitching = "pm-5594-safari-account-switching", /* Autofill */ + UseUndeterminedCipherScenarioTriggeringLogic = "undetermined-cipher-scenario-logic", MacOsNativeCredentialSync = "macos-native-credential-sync", WindowsDesktopAutotype = "windows-desktop-autotype", WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga", @@ -42,6 +45,7 @@ export enum FeatureFlag { ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2", NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change", + PasskeyUnlock = "pm-2035-passkey-unlock", DataRecoveryTool = "pm-28813-data-recovery-tool", ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component", PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit", @@ -58,19 +62,19 @@ export enum FeatureFlag { EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike", EventManagementForHuntress = "event-management-for-huntress", PhishingDetection = "phishing-detection", + Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements", /* Vault */ PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", CipherKeyEncryption = "cipher-key-encryption", - RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", - VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems", + PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk", /* Platform */ - IpcChannelFramework = "ipc-channel-framework", + ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework", /* Innovation */ PM19148_InnovationArchive = "pm-19148-innovation-archive", @@ -103,10 +107,11 @@ export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.AutoConfirm]: FALSE, [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, - [FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE, + [FeatureFlag.DefaultUserCollectionRestore]: FALSE, [FeatureFlag.MembersComponentRefactor]: FALSE, /* Autofill */ + [FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE, [FeatureFlag.MacOsNativeCredentialSync]: FALSE, [FeatureFlag.WindowsDesktopAutotype]: FALSE, [FeatureFlag.WindowsDesktopAutotypeGA]: FALSE, @@ -122,19 +127,21 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE, [FeatureFlag.EventManagementForHuntress]: FALSE, [FeatureFlag.PhishingDetection]: FALSE, + [FeatureFlag.Milestone11AppPageImprovements]: FALSE, /* Vault */ [FeatureFlag.CipherKeyEncryption]: FALSE, [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, - [FeatureFlag.RiskInsightsForPremium]: FALSE, - [FeatureFlag.VaultLoadingSkeletons]: FALSE, [FeatureFlag.BrowserPremiumSpotlight]: FALSE, + [FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE, [FeatureFlag.MigrateMyVaultToMyItems]: FALSE, /* Auth */ [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, + [FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE, + [FeatureFlag.SafariAccountSwitching]: FALSE, /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, @@ -153,6 +160,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.ForceUpdateKDFSettings]: FALSE, [FeatureFlag.LinuxBiometricsV2]: FALSE, [FeatureFlag.NoLogoutOnKdfChange]: FALSE, + [FeatureFlag.PasskeyUnlock]: FALSE, [FeatureFlag.DataRecoveryTool]: FALSE, [FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE, [FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE, @@ -160,7 +168,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE, /* Platform */ - [FeatureFlag.IpcChannelFramework]: FALSE, + [FeatureFlag.ContentScriptIpcChannelFramework]: FALSE, /* Innovation */ [FeatureFlag.PM19148_InnovationArchive]: FALSE, diff --git a/libs/common/src/key-management/models/response/user-decryption.response.ts b/libs/common/src/key-management/models/response/user-decryption.response.ts index b3ac5b80b32..b662834ab01 100644 --- a/libs/common/src/key-management/models/response/user-decryption.response.ts +++ b/libs/common/src/key-management/models/response/user-decryption.response.ts @@ -1,9 +1,15 @@ +import { WebAuthnPrfDecryptionOptionResponse } from "../../../auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response"; import { BaseResponse } from "../../../models/response/base.response"; import { MasterPasswordUnlockResponse } from "../../master-password/models/response/master-password-unlock.response"; export class UserDecryptionResponse extends BaseResponse { masterPasswordUnlock?: MasterPasswordUnlockResponse; + /** + * The sync service returns an array of WebAuthn PRF options. + */ + webAuthnPrfOptions?: WebAuthnPrfDecryptionOptionResponse[]; + constructor(response: unknown) { super(response); @@ -11,5 +17,12 @@ export class UserDecryptionResponse extends BaseResponse { if (masterPasswordUnlock != null && typeof masterPasswordUnlock === "object") { this.masterPasswordUnlock = new MasterPasswordUnlockResponse(masterPasswordUnlock); } + + const webAuthnPrfOptions = this.getResponseProperty("WebAuthnPrfOptions"); + if (webAuthnPrfOptions != null && Array.isArray(webAuthnPrfOptions)) { + this.webAuthnPrfOptions = webAuthnPrfOptions.map( + (option) => new WebAuthnPrfDecryptionOptionResponse(option), + ); + } } } diff --git a/libs/common/src/key-management/pin/pin-state.service.abstraction.ts b/libs/common/src/key-management/pin/pin-state.service.abstraction.ts index 4aef268c1c4..d577d75ef6f 100644 --- a/libs/common/src/key-management/pin/pin-state.service.abstraction.ts +++ b/libs/common/src/key-management/pin/pin-state.service.abstraction.ts @@ -11,6 +11,20 @@ import { PinLockType } from "./pin-lock-type"; * The PinStateService manages the storage and retrieval of PIN-related state for user accounts. */ export abstract class PinStateServiceAbstraction { + /** + * Checks if a user is enrolled into PIN unlock + * @param userId The user's id + * @throws If the user id is not provided + */ + abstract pinSet$(userId: UserId): Observable; + + /** + * Gets the user's {@link PinLockType} + * @param userId The user's id + * @throws If the user id is not provided + */ + abstract pinLockType$(userId: UserId): Observable; + /** * Gets the user's UserKey encrypted PIN * @deprecated - This is not a public API. DO NOT USE IT @@ -21,17 +35,12 @@ export abstract class PinStateServiceAbstraction { /** * Gets the user's {@link PinLockType} + * @deprecated Use {@link pinLockType$} instead * @param userId The user's id * @throws If the user id is not provided */ abstract getPinLockType(userId: UserId): Promise; - /** - * Checks if a user is enrolled into PIN unlock - * @param userId The user's id - */ - abstract isPinSet(userId: UserId): Promise; - /** * Gets the user's PIN-protected UserKey envelope, either persistent or ephemeral based on the provided PinLockType * @deprecated - This is not a public API. DO NOT USE IT diff --git a/libs/common/src/key-management/pin/pin-state.service.implementation.ts b/libs/common/src/key-management/pin/pin-state.service.implementation.ts index d5b2608f280..10046191c01 100644 --- a/libs/common/src/key-management/pin/pin-state.service.implementation.ts +++ b/libs/common/src/key-management/pin/pin-state.service.implementation.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, map, Observable } from "rxjs"; +import { combineLatest, firstValueFrom, map, Observable } from "rxjs"; import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal"; import { StateProvider } from "@bitwarden/state"; @@ -26,27 +26,36 @@ export class PinStateService implements PinStateServiceAbstraction { .pipe(map((value) => (value ? new EncString(value) : null))); } - async isPinSet(userId: UserId): Promise { + pinSet$(userId: UserId): Observable { assertNonNullish(userId, "userId"); - return (await this.getPinLockType(userId)) !== "DISABLED"; + return this.pinLockType$(userId).pipe(map((pinLockType) => pinLockType !== "DISABLED")); + } + + pinLockType$(userId: UserId): Observable { + assertNonNullish(userId, "userId"); + + return combineLatest([ + this.pinProtectedUserKeyEnvelope$(userId, "PERSISTENT").pipe(map((key) => key != null)), + this.stateProvider + .getUserState$(USER_KEY_ENCRYPTED_PIN, userId) + .pipe(map((key) => key != null)), + ]).pipe( + map(([isPersistentPinSet, isPinSet]) => { + if (isPersistentPinSet) { + return "PERSISTENT"; + } else if (isPinSet) { + return "EPHEMERAL"; + } else { + return "DISABLED"; + } + }), + ); } async getPinLockType(userId: UserId): Promise { assertNonNullish(userId, "userId"); - const isPersistentPinSet = - (await this.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null; - const isPinSet = - (await firstValueFrom(this.stateProvider.getUserState$(USER_KEY_ENCRYPTED_PIN, userId))) != - null; - - if (isPersistentPinSet) { - return "PERSISTENT"; - } else if (isPinSet) { - return "EPHEMERAL"; - } else { - return "DISABLED"; - } + return await firstValueFrom(this.pinLockType$(userId)); } async getPinProtectedUserKeyEnvelope( @@ -55,17 +64,7 @@ export class PinStateService implements PinStateServiceAbstraction { ): Promise { assertNonNullish(userId, "userId"); - if (pinLockType === "EPHEMERAL") { - return await firstValueFrom( - this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, userId), - ); - } else if (pinLockType === "PERSISTENT") { - return await firstValueFrom( - this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, userId), - ); - } else { - throw new Error(`Unsupported PinLockType: ${pinLockType}`); - } + return await firstValueFrom(this.pinProtectedUserKeyEnvelope$(userId, pinLockType)); } async setPinState( @@ -110,4 +109,19 @@ export class PinStateService implements PinStateServiceAbstraction { await this.stateProvider.setUserState(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, null, userId); } + + private pinProtectedUserKeyEnvelope$( + userId: UserId, + pinLockType: PinLockType, + ): Observable { + assertNonNullish(userId, "userId"); + + if (pinLockType === "EPHEMERAL") { + return this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, userId); + } else if (pinLockType === "PERSISTENT") { + return this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, userId); + } else { + throw new Error(`Unsupported PinLockType: ${pinLockType}`); + } + } } diff --git a/libs/common/src/key-management/pin/pin-state.service.spec.ts b/libs/common/src/key-management/pin/pin-state.service.spec.ts index 7406701c28d..42dcce9fedc 100644 --- a/libs/common/src/key-management/pin/pin-state.service.spec.ts +++ b/libs/common/src/key-management/pin/pin-state.service.spec.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal"; @@ -94,14 +94,50 @@ describe("PinStateService", () => { }); }); - describe("getPinLockType()", () => { + describe("pinSet$", () => { beforeEach(() => { jest.clearAllMocks(); }); it("should throw an error if userId is null", async () => { // Act & Assert - await expect(sut.getPinLockType(null as any)).rejects.toThrow("userId"); + expect(() => sut.pinSet$(null as any)).toThrow("userId"); + }); + + it("should return false when pin lock type is DISABLED", async () => { + // Arrange + jest.spyOn(sut, "pinLockType$").mockReturnValue(of("DISABLED")); + + // Act + const result = await firstValueFrom(sut.pinSet$(mockUserId)); + + // Assert + expect(result).toBe(false); + }); + + it.each([["PERSISTENT" as PinLockType], ["EPHEMERAL" as PinLockType]])( + "should return true when pin lock type is %s", + async (pinLockType) => { + // Arrange + jest.spyOn(sut, "pinLockType$").mockReturnValue(of(pinLockType)); + + // Act + const result = await firstValueFrom(sut.pinSet$(mockUserId)); + + // Assert + expect(result).toBe(true); + }, + ); + }); + + describe("pinLockType$", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should throw an error if userId is null", async () => { + // Act & Assert + expect(() => sut.pinLockType$(null as any)).toThrow("userId"); }); it("should return 'PERSISTENT' if a pin protected user key (persistent) is found", async () => { @@ -114,7 +150,7 @@ describe("PinStateService", () => { ); // Act - const result = await sut.getPinLockType(mockUserId); + const result = await firstValueFrom(sut.pinLockType$(mockUserId)); // Assert expect(result).toBe("PERSISTENT"); @@ -125,7 +161,7 @@ describe("PinStateService", () => { await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, mockUserKeyEncryptedPin, mockUserId); // Act - const result = await sut.getPinLockType(mockUserId); + const result = await firstValueFrom(sut.pinLockType$(mockUserId)); // Assert expect(result).toBe("EPHEMERAL"); @@ -135,7 +171,7 @@ describe("PinStateService", () => { // Arrange - don't set any PIN-related state // Act - const result = await sut.getPinLockType(mockUserId); + const result = await firstValueFrom(sut.pinLockType$(mockUserId)); // Assert expect(result).toBe("DISABLED"); @@ -151,7 +187,7 @@ describe("PinStateService", () => { await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, mockUserId); // Act - const result = await sut.getPinLockType(mockUserId); + const result = await firstValueFrom(sut.pinLockType$(mockUserId)); // Assert expect(result).toBe("DISABLED"); diff --git a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts index 697b8a1875c..44108b69513 100644 --- a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts @@ -20,10 +20,9 @@ export abstract class VaultTimeoutSettingsService { /** * Get the available vault timeout actions for the current user * - * **NOTE:** This observable is not yet connected to the state service, so it will not update when the state changes * @param userId The user id to check. If not provided, the current user is used */ - abstract availableVaultTimeoutActions$(userId?: string): Observable; + abstract availableVaultTimeoutActions$(userId?: UserId): Observable; /** * Evaluates the user's available vault timeout actions and returns a boolean representing @@ -55,5 +54,5 @@ export abstract class VaultTimeoutSettingsService { * @param userId The user id to check. If not provided, the current user is used * @returns boolean true if biometric lock is set */ - abstract isBiometricLockSet(userId?: string): Promise; + abstract isBiometricLockSet(userId?: UserId): Promise; } diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts index 3c391344f04..3fa71598e65 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts @@ -78,7 +78,8 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService = createVaultTimeoutSettingsService(defaultVaultTimeout); - biometricStateService.biometricUnlockEnabled$ = of(false); + pinStateService.pinSet$.mockReturnValue(of(false)); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false)); }); afterEach(() => { @@ -86,72 +87,121 @@ describe("VaultTimeoutSettingsService", () => { }); describe("availableVaultTimeoutActions$", () => { - it("always returns LogOut", async () => { - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + describe("when no userId provided (active user)", () => { + it("always returns LogOut", async () => { + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); - expect(result).toContain(VaultTimeoutAction.LogOut); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); + + it("contains Lock when the user has a master password", async () => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); + + expect(userDecryptionOptionsService.hasMasterPasswordById$).toHaveBeenCalledWith( + mockUserId, + ); + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => { + pinStateService.pinSet$.mockReturnValue(of(true)); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); + + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("contains Lock when the user has biometrics configured", async () => { + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true)); + biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); + + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false })); + pinStateService.pinSet$.mockReturnValue(of(false)); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false)); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); + + expect(result).not.toContain(VaultTimeoutAction.Lock); + }); + + it("should throw error when activeAccount$ is null", async () => { + accountService.activeAccountSubject.next(null); + + const result$ = vaultTimeoutSettingsService.availableVaultTimeoutActions$(); + + await expect(firstValueFrom(result$)).rejects.toThrow("Null or undefined account"); + }); }); - it("contains Lock when the user has a master password", async () => { - userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + describe("with explicit userId parameter", () => { + it("should return Lock and LogOut when provided user has master password", async () => { + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true)); - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId), + ); - expect(result).toContain(VaultTimeoutAction.Lock); - }); + expect(userDecryptionOptionsService.hasMasterPasswordById$).toHaveBeenCalledWith( + mockUserId, + ); + expect(result).toContain(VaultTimeoutAction.Lock); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); - it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => { - pinStateService.isPinSet.mockResolvedValue(true); + it("should return Lock and LogOut when provided user has PIN configured", async () => { + pinStateService.pinSet$.mockReturnValue(of(true)); - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId), + ); - expect(result).toContain(VaultTimeoutAction.Lock); - }); + expect(pinStateService.pinSet$).toHaveBeenCalledWith(mockUserId); + expect(result).toContain(VaultTimeoutAction.Lock); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); - it("contains Lock when the user has biometrics configured", async () => { - biometricStateService.biometricUnlockEnabled$ = of(true); - biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true); + it("should return Lock and LogOut when provided user has biometrics configured", async () => { + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true)); - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId), + ); - expect(result).toContain(VaultTimeoutAction.Lock); - }); + expect(biometricStateService.biometricUnlockEnabled$).toHaveBeenCalledWith(mockUserId); + expect(result).toContain(VaultTimeoutAction.Lock); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); - it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => { - userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false })); - pinStateService.isPinSet.mockResolvedValue(false); - biometricStateService.biometricUnlockEnabled$ = of(false); + it("should not return Lock when provided user has no unlock methods", async () => { + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false)); + pinStateService.pinSet$.mockReturnValue(of(false)); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false)); - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId), + ); - expect(result).not.toContain(VaultTimeoutAction.Lock); - }); - - it("should return only LogOut when userId is not provided and there is no active account", async () => { - // Set up accountService to return null for activeAccount - accountService.activeAccount$ = of(null); - pinStateService.isPinSet.mockResolvedValue(false); - biometricStateService.biometricUnlockEnabled$ = of(false); - - // Call availableVaultTimeoutActions$ which internally calls userHasMasterPassword without a userId - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); - - // Since there's no active account, userHasMasterPassword returns false, - // meaning no master password is available, so Lock should not be available - expect(result).toEqual([VaultTimeoutAction.LogOut]); - expect(result).not.toContain(VaultTimeoutAction.Lock); + expect(result).not.toContain(VaultTimeoutAction.Lock); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); }); }); @@ -237,8 +287,8 @@ describe("VaultTimeoutSettingsService", () => { `( "returns $expected when policy is $policy, has PIN unlock method: $hasPinUnlock or Biometric unlock method: $hasBiometricUnlock, and user preference is $userPreference", async ({ hasPinUnlock, hasBiometricUnlock, policy, userPreference, expected }) => { - biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(hasBiometricUnlock); - pinStateService.isPinSet.mockResolvedValue(hasPinUnlock); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(hasBiometricUnlock)); + pinStateService.pinSet$.mockReturnValue(of(hasPinUnlock)); userDecryptionOptionsSubject.next( new UserDecryptionOptions({ hasMasterPassword: false }), diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts index 57e484fd767..5384d6860b7 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts @@ -3,16 +3,15 @@ import { catchError, combineLatest, - defer, distinctUntilChanged, EMPTY, firstValueFrom, from, map, + of, Observable, shareReplay, switchMap, - tap, concatMap, } from "rxjs"; @@ -28,6 +27,7 @@ import { PolicyType } from "../../../admin-console/enums"; import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service"; import { AccountService } from "../../../auth/abstractions/account.service"; import { TokenService } from "../../../auth/abstractions/token.service"; +import { getUserId } from "../../../auth/services/account.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; @@ -101,8 +101,29 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA await this.keyService.refreshAdditionalKeys(userId); } - availableVaultTimeoutActions$(userId?: string): Observable { - return defer(() => this.getAvailableVaultTimeoutActions(userId)); + availableVaultTimeoutActions$(userId?: UserId): Observable { + const userId$ = + userId != null + ? of(userId) + : // TODO remove with https://bitwarden.atlassian.net/browse/PM-10647 + getUserId(this.accountService.activeAccount$); + + return userId$.pipe( + switchMap((userId) => + combineLatest([ + this.userDecryptionOptionsService.hasMasterPasswordById$(userId), + this.biometricStateService.biometricUnlockEnabled$(userId), + this.pinStateService.pinSet$(userId), + ]), + ), + map(([haveMasterPassword, biometricUnlockEnabled, isPinSet]) => { + const canLock = haveMasterPassword || biometricUnlockEnabled || isPinSet; + if (canLock) { + return [VaultTimeoutAction.LogOut, VaultTimeoutAction.Lock]; + } + return [VaultTimeoutAction.LogOut]; + }), + ); } async canLock(userId: UserId): Promise { @@ -112,12 +133,8 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return availableVaultTimeoutActions?.includes(VaultTimeoutAction.Lock) || false; } - async isBiometricLockSet(userId?: string): Promise { - const biometricUnlockPromise = - userId == null - ? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$) - : this.biometricStateService.getBiometricUnlockEnabled(userId as UserId); - return await biometricUnlockPromise; + async isBiometricLockSet(userId?: UserId): Promise { + return await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$(userId)); } private async setVaultTimeout(userId: UserId, timeout: VaultTimeout): Promise { @@ -262,45 +279,45 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return combineLatest([ this.stateProvider.getUserState$(VAULT_TIMEOUT_ACTION, userId), this.getMaxSessionTimeoutPolicyDataByUserId$(userId), + this.availableVaultTimeoutActions$(userId), ]).pipe( - switchMap(([currentVaultTimeoutAction, maxSessionTimeoutPolicyData]) => { - return from( - this.determineVaultTimeoutAction( - userId, + concatMap( + async ([ + currentVaultTimeoutAction, + maxSessionTimeoutPolicyData, + availableVaultTimeoutActions, + ]) => { + const vaultTimeoutAction = this.determineVaultTimeoutAction( + availableVaultTimeoutActions, currentVaultTimeoutAction, maxSessionTimeoutPolicyData, - ), - ).pipe( - tap((vaultTimeoutAction: VaultTimeoutAction) => { - // As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current - // We want to avoid having a null timeout action always so we set it to the default if it is null - // and if the user becomes subject to a policy that requires a specific action, we set it to that - if (vaultTimeoutAction !== currentVaultTimeoutAction) { - return this.stateProvider.setUserState( - VAULT_TIMEOUT_ACTION, - vaultTimeoutAction, - userId, - ); - } - }), - catchError((error: unknown) => { - // Protect outer observable from canceling on error by catching and returning EMPTY - this.logService.error(`Error getting vault timeout: ${error}`); - return EMPTY; - }), - ); + ); + + // As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current + // We want to avoid having a null timeout action always so we set it to the default if it is null + // and if the user becomes subject to a policy that requires a specific action, we set it to that + if (vaultTimeoutAction !== currentVaultTimeoutAction) { + await this.stateProvider.setUserState(VAULT_TIMEOUT_ACTION, vaultTimeoutAction, userId); + } + + return vaultTimeoutAction; + }, + ), + catchError((error: unknown) => { + // Protect outer observable from canceling on error by catching and returning EMPTY + this.logService.error(`Error getting vault timeout: ${error}`); + return EMPTY; }), distinctUntilChanged(), // Avoid having the set side effect trigger a new emission of the same action shareReplay({ refCount: true, bufferSize: 1 }), ); } - private async determineVaultTimeoutAction( - userId: string, + private determineVaultTimeoutAction( + availableVaultTimeoutActions: VaultTimeoutAction[], currentVaultTimeoutAction: VaultTimeoutAction | null, maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, - ): Promise { - const availableVaultTimeoutActions = await this.getAvailableVaultTimeoutActions(userId); + ): VaultTimeoutAction { if (availableVaultTimeoutActions.length === 1) { return availableVaultTimeoutActions[0]; } @@ -339,38 +356,4 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null), ); } - - private async getAvailableVaultTimeoutActions(userId?: string): Promise { - userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; - - const availableActions = [VaultTimeoutAction.LogOut]; - - const canLock = - (await this.userHasMasterPassword(userId)) || - (await this.pinStateService.isPinSet(userId as UserId)) || - (await this.isBiometricLockSet(userId)); - - if (canLock) { - availableActions.push(VaultTimeoutAction.Lock); - } - - return availableActions; - } - - private async userHasMasterPassword(userId: string): Promise { - let resolvedUserId: UserId; - if (userId) { - resolvedUserId = userId as UserId; - } else { - const activeAccount = await firstValueFrom(this.accountService.activeAccount$); - if (!activeAccount) { - return false; // No account, can't have master password - } - resolvedUserId = activeAccount.id; - } - - return await firstValueFrom( - this.userDecryptionOptionsService.hasMasterPasswordById$(resolvedUserId), - ); - } } diff --git a/libs/common/src/models/export/fido2-credential.export.ts b/libs/common/src/models/export/fido2-credential.export.ts index ce9c754fea3..46131a67060 100644 --- a/libs/common/src/models/export/fido2-credential.export.ts +++ b/libs/common/src/models/export/fido2-credential.export.ts @@ -75,7 +75,7 @@ export class Fido2CredentialExport { domain.userDisplayName = req.userDisplayName != null ? new EncString(req.userDisplayName) : null; domain.discoverable = req.discoverable != null ? new EncString(req.discoverable) : null; - domain.creationDate = req.creationDate; + domain.creationDate = req.creationDate != null ? new Date(req.creationDate) : null; return domain; } @@ -111,10 +111,12 @@ export class Fido2CredentialExport { this.rpId = safeGetString(o.rpId); this.userHandle = safeGetString(o.userHandle); this.userName = safeGetString(o.userName); - this.counter = safeGetString(String(o.counter)); + this.counter = safeGetString(o instanceof Fido2CredentialView ? String(o.counter) : o.counter); this.rpName = safeGetString(o.rpName); this.userDisplayName = safeGetString(o.userDisplayName); - this.discoverable = safeGetString(String(o.discoverable)); + this.discoverable = safeGetString( + o instanceof Fido2CredentialView ? String(o.discoverable) : o.discoverable, + ); this.creationDate = o.creationDate; } } diff --git a/libs/common/src/models/export/login.export.ts b/libs/common/src/models/export/login.export.ts index b727c614bdf..9d926e5ede5 100644 --- a/libs/common/src/models/export/login.export.ts +++ b/libs/common/src/models/export/login.export.ts @@ -39,7 +39,11 @@ export class LoginExport { domain.username = req.username != null ? new EncString(req.username) : null; domain.password = req.password != null ? new EncString(req.password) : null; domain.totp = req.totp != null ? new EncString(req.totp) : null; - // Fido2credentials are currently not supported for exports. + if (req.fido2Credentials != null) { + domain.fido2Credentials = req.fido2Credentials.map((f2) => + Fido2CredentialExport.toDomain(f2), + ); + } return domain; } diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts index 136b0ac394f..bdbfc4ea17b 100644 --- a/libs/common/src/platform/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -42,6 +42,7 @@ export class Utils { static readonly validHosts: string[] = ["localhost"]; static readonly originalMinimumPasswordLength = 8; static readonly minimumPasswordLength = 12; + static readonly maximumPasswordLength = 128; static readonly DomainMatchBlacklist = new Map>([ ["google.com", new Set(["script.google.com"])], ]); diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts index 9c50bd1ab65..6223e4274bf 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts @@ -254,17 +254,17 @@ describe("FidoAuthenticatorService", () => { } it("should save credential to vault if request confirmed by user", async () => { - const encryptedCipher = Symbol(); userInterfaceSession.confirmNewCredential.mockResolvedValue({ cipherId: existingCipher.id, userVerified: false, }); - cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as EncryptionContext); await authenticator.makeCredential(params, windowReference); - const saved = cipherService.encrypt.mock.lastCall?.[0]; - expect(saved).toEqual( + const savedCipher = cipherService.updateWithServer.mock.lastCall?.[0]; + const actualUserId = cipherService.updateWithServer.mock.lastCall?.[1]; + expect(actualUserId).toEqual(userId); + expect(savedCipher).toEqual( expect.objectContaining({ type: CipherType.Login, name: existingCipher.name, @@ -288,7 +288,6 @@ describe("FidoAuthenticatorService", () => { }), }), ); - expect(cipherService.updateWithServer).toHaveBeenCalledWith(encryptedCipher); }); /** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */ @@ -361,17 +360,14 @@ describe("FidoAuthenticatorService", () => { cipherService.getAllDecrypted.mockResolvedValue([await cipher]); cipherService.decrypt.mockResolvedValue(cipher); - cipherService.encrypt.mockImplementation(async (cipher) => { - cipher.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability - return { cipher: {} as any as Cipher, encryptedFor: userId }; - }); - cipherService.createWithServer.mockImplementation(async ({ cipher }) => { - cipher.id = cipherId; + cipherService.createWithServer.mockImplementation(async (cipherView, _userId) => { + cipherView.id = cipherId; return cipher; }); - cipherService.updateWithServer.mockImplementation(async ({ cipher }) => { - cipher.id = cipherId; - return cipher; + cipherService.updateWithServer.mockImplementation(async (cipherView, _userId) => { + cipherView.id = cipherId; + cipherView.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability + return cipherView; }); }); @@ -701,14 +697,11 @@ describe("FidoAuthenticatorService", () => { /** Spec: Increment the credential associated signature counter */ it("should increment counter and save to server when stored counter is larger than zero", async () => { - const encrypted = Symbol(); - cipherService.encrypt.mockResolvedValue(encrypted as any); ciphers[0].login.fido2Credentials[0].counter = 9000; await authenticator.getAssertion(params, windowReference); - expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted); - expect(cipherService.encrypt).toHaveBeenCalledWith( + expect(cipherService.updateWithServer).toHaveBeenCalledWith( expect.objectContaining({ id: ciphers[0].id, login: expect.objectContaining({ @@ -725,8 +718,6 @@ describe("FidoAuthenticatorService", () => { /** Spec: Authenticators that do not implement a signature counter leave the signCount in the authenticator data constant at zero. */ it("should not save to server when stored counter is zero", async () => { - const encrypted = Symbol(); - cipherService.encrypt.mockResolvedValue(encrypted as any); ciphers[0].login.fido2Credentials[0].counter = 0; await authenticator.getAssertion(params, windowReference); diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts index d1081e9f7b2..1b150207290 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts @@ -187,8 +187,7 @@ export class Fido2AuthenticatorService< if (Utils.isNullOrEmpty(cipher.login.username)) { cipher.login.username = fido2Credential.userName; } - const reencrypted = await this.cipherService.encrypt(cipher, activeUserId); - await this.cipherService.updateWithServer(reencrypted); + await this.cipherService.updateWithServer(cipher, activeUserId); await this.cipherService.clearCache(activeUserId); credentialId = fido2Credential.credentialId; } catch (error) { @@ -328,8 +327,7 @@ export class Fido2AuthenticatorService< const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getUserId), ); - const encrypted = await this.cipherService.encrypt(selectedCipher, activeUserId); - await this.cipherService.updateWithServer(encrypted); + await this.cipherService.updateWithServer(selectedCipher, activeUserId); await this.cipherService.clearCache(activeUserId); } diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index 5084f5f5f18..e2c9c77e204 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -80,7 +80,7 @@ export class DefaultSdkService implements SdkService { client$ = this.environmentService.environment$.pipe( concatMap(async (env) => { await SdkLoadService.Ready; - const settings = this.toSettings(env); + const settings = await this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( new JsTokenProvider(this.apiService), settings, @@ -210,7 +210,7 @@ export class DefaultSdkService implements SdkService { return undefined; } - const settings = this.toSettings(env); + const settings = await this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( new JsTokenProvider(this.apiService, userId), settings, @@ -322,11 +322,12 @@ export class DefaultSdkService implements SdkService { client.platform().load_flags(featureFlagMap); } - private toSettings(env: Environment): ClientSettings { + private async toSettings(env: Environment): Promise { return { apiUrl: env.getApiUrl(), identityUrl: env.getIdentityUrl(), deviceType: toSdkDevice(this.platformUtilsService.getDevice()), + bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(), userAgent: this.userAgent ?? navigator.userAgent, }; } diff --git a/libs/common/src/platform/services/sdk/register-sdk.service.ts b/libs/common/src/platform/services/sdk/register-sdk.service.ts index a222807640f..073c5c0560c 100644 --- a/libs/common/src/platform/services/sdk/register-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/register-sdk.service.ts @@ -62,7 +62,7 @@ export class DefaultRegisterSdkService implements RegisterSdkService { client$ = this.environmentService.environment$.pipe( concatMap(async (env) => { await SdkLoadService.Ready; - const settings = this.toSettings(env); + const settings = await this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( new JsTokenProvider(this.apiService), settings, @@ -137,7 +137,7 @@ export class DefaultRegisterSdkService implements RegisterSdkService { return undefined; } - const settings = this.toSettings(env); + const settings = await this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( new JsTokenProvider(this.apiService, userId), settings, @@ -185,12 +185,13 @@ export class DefaultRegisterSdkService implements RegisterSdkService { client.platform().load_flags(featureFlagMap); } - private toSettings(env: Environment): ClientSettings { + private async toSettings(env: Environment): Promise { return { apiUrl: env.getApiUrl(), identityUrl: env.getIdentityUrl(), deviceType: toSdkDevice(this.platformUtilsService.getDevice()), userAgent: this.userAgent ?? navigator.userAgent, + bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(), }; } } diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index fc83954ee7d..bf086ceceaf 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -9,7 +9,7 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { LogoutReason, UserDecryptionOptions, - UserDecryptionOptionsServiceAbstraction, + InternalUserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -68,7 +68,7 @@ describe("DefaultSyncService", () => { let folderApiService: MockProxy; let organizationService: MockProxy; let sendApiService: MockProxy; - let userDecryptionOptionsService: MockProxy; + let userDecryptionOptionsService: MockProxy; let avatarService: MockProxy; let logoutCallback: jest.Mock, [logoutReason: LogoutReason, userId?: UserId]>; let billingAccountProfileStateService: MockProxy; diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 3c8f6e57e1e..52de14bbc67 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -6,8 +6,8 @@ import { firstValueFrom, map } from "rxjs"; // eslint-disable-next-line no-restricted-imports import { CollectionService } from "@bitwarden/admin-console/common"; import { - CollectionDetailsResponse, CollectionData, + CollectionDetailsResponse, } from "@bitwarden/common/admin-console/models/collections"; import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service"; import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; @@ -15,9 +15,13 @@ import { SecurityStateService } from "@bitwarden/common/key-management/security- // eslint-disable-next-line no-restricted-imports import { KdfConfigService, KeyService } from "@bitwarden/key-management"; -// FIXME: remove `src` and fix import +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { UserDecryptionOptionsServiceAbstraction } from "../../../../auth/src/common/abstractions"; +import { + InternalUserDecryptionOptionsServiceAbstraction, + UserDecryptionOptions, + WebAuthnPrfUserDecryptionOption, +} from "../../../../auth/src/common"; // FIXME: remove `src` and fix import // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "../../../../auth/src/common/types"; @@ -93,7 +97,7 @@ export class DefaultSyncService extends CoreSyncService { folderApiService: FolderApiServiceAbstraction, private organizationService: InternalOrganizationServiceAbstraction, sendApiService: SendApiService, - private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, private avatarService: AvatarService, private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise, private billingAccountProfileStateService: BillingAccountProfileStateService, @@ -450,5 +454,43 @@ export class DefaultSyncService extends CoreSyncService { ); await this.kdfConfigService.setKdfConfig(userId, masterPasswordUnlockData.kdf); } + + // Update WebAuthn PRF options if present + if (userDecryption.webAuthnPrfOptions != null && userDecryption.webAuthnPrfOptions.length > 0) { + try { + // Only update if this is the active user, since setUserDecryptionOptions() + // operates on the active user's state + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + if (activeAccount?.id !== userId) { + return; + } + + // Get current options without blocking if they don't exist yet + const currentUserDecryptionOptions = await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + ).catch((): UserDecryptionOptions | null => { + return null; + }); + + if (currentUserDecryptionOptions != null) { + // Update the PRF options while preserving other decryption options + const updatedOptions = Object.assign( + new UserDecryptionOptions(), + currentUserDecryptionOptions, + ); + updatedOptions.webAuthnPrfOptions = userDecryption.webAuthnPrfOptions + .map((option) => WebAuthnPrfUserDecryptionOption.fromResponse(option)) + .filter((option) => option !== undefined); + + await this.userDecryptionOptionsService.setUserDecryptionOptionsById( + activeAccount.id, + updatedOptions, + ); + } + } catch (error) { + this.logService.error("[Sync] Failed to update WebAuthn PRF options:", error); + } + } } } diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index 7eeb15f3ebe..4081eba2878 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -11,7 +11,6 @@ export class SendData { id: string; accessId: string; type: SendType; - authType: AuthType; name: string; notes: string; file: SendFileData; @@ -24,8 +23,10 @@ export class SendData { deletionDate: string; password: string; emails: string; + emailHashes: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(response?: SendResponse) { if (response == null) { @@ -46,8 +47,10 @@ export class SendData { this.deletionDate = response.deletionDate; this.password = response.password; this.emails = response.emails; + this.emailHashes = ""; this.disabled = response.disable; this.hideEmail = response.hideEmail; + this.authType = response.authType; switch (this.type) { case SendType.Text: 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 cd51390908e..f660333c917 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { emptyGuid, UserId } from "@bitwarden/common/types/guid"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -15,7 +16,6 @@ import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; -import { Send } from "./send"; import { SendText } from "./send-text"; describe("Send", () => { @@ -26,7 +26,6 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, - authType: AuthType.None, name: "encName", notes: "encNotes", text: { @@ -41,9 +40,11 @@ describe("Send", () => { expirationDate: "2022-01-31T12:00:00.000Z", deletionDate: "2022-01-31T12:00:00.000Z", password: "password", - emails: null!, + emails: "", + emailHashes: "", disabled: false, hideEmail: true, + authType: AuthType.None, }; mockContainerService(); @@ -69,6 +70,8 @@ describe("Send", () => { expirationDate: null, deletionDate: null, password: undefined, + emails: null, + emailHashes: undefined, disabled: undefined, hideEmail: undefined, }); @@ -81,7 +84,6 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, - authType: AuthType.None, name: { encryptedString: "encName", encryptionType: 0 }, notes: { encryptedString: "encNotes", encryptionType: 0 }, text: { @@ -95,9 +97,11 @@ describe("Send", () => { expirationDate: new Date("2022-01-31T12:00:00.000Z"), deletionDate: new Date("2022-01-31T12:00:00.000Z"), password: "password", - emails: null!, + emails: null, + emailHashes: "", disabled: false, hideEmail: true, + authType: AuthType.None, }); }); @@ -121,14 +125,22 @@ describe("Send", () => { send.expirationDate = new Date("2022-01-31T12:00:00.000Z"); send.deletionDate = new Date("2022-01-31T12:00:00.000Z"); send.password = "password"; + send.emails = null; send.disabled = false; send.hideEmail = true; + send.authType = AuthType.None; const encryptService = mock(); const keyService = mock(); encryptService.decryptBytes .calledWith(send.key, userKey) .mockResolvedValue(makeStaticByteArray(32)); + encryptService.decryptString + .calledWith(send.name, "cryptoKey" as any) + .mockResolvedValue("name"); + encryptService.decryptString + .calledWith(send.notes, "cryptoKey" as any) + .mockResolvedValue("notes"); keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); keyService.userKey$.calledWith(userId).mockReturnValue(of(userKey)); @@ -137,12 +149,6 @@ describe("Send", () => { const view = await send.decrypt(userId); expect(text.decrypt).toHaveBeenNthCalledWith(1, "cryptoKey"); - expect(send.name.decrypt).toHaveBeenNthCalledWith( - 1, - null, - "cryptoKey", - "Property: name; ObjectContext: No Domain Context", - ); expect(view).toMatchObject({ id: "id", @@ -150,7 +156,6 @@ describe("Send", () => { name: "name", notes: "notes", type: 0, - authType: 2, key: expect.anything(), cryptoKey: "cryptoKey", file: expect.anything(), @@ -161,8 +166,265 @@ describe("Send", () => { expirationDate: new Date("2022-01-31T12:00:00.000Z"), deletionDate: new Date("2022-01-31T12:00:00.000Z"), password: "password", + emails: [], disabled: false, hideEmail: true, + authType: AuthType.None, + }); + }); + + describe("Email decryption", () => { + let encryptService: jest.Mocked; + let keyService: jest.Mocked; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const userId = emptyGuid as UserId; + + beforeEach(() => { + encryptService = mock(); + keyService = mock(); + encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32)); + keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); + keyService.userKey$.mockReturnValue(of(userKey)); + (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + }); + + it("should decrypt and parse single email", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc("test@example.com"); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve("test@example.com"); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(encryptService.decryptString).toHaveBeenCalledWith(send.emails, "cryptoKey"); + expect(view.emails).toEqual(["test@example.com"]); + }); + + it("should decrypt and parse multiple emails", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc("test@example.com,user@test.com,admin@domain.com"); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve("test@example.com,user@test.com,admin@domain.com"); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual(["test@example.com", "user@test.com", "admin@domain.com"]); + }); + + it("should trim whitespace from decrypted emails", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc(" test@example.com , user@test.com "); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve(" test@example.com , user@test.com "); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual(["test@example.com", "user@test.com"]); + }); + + it("should return empty array when emails is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual([]); + expect(encryptService.decryptString).not.toHaveBeenCalledWith(expect.anything(), "cryptoKey"); + }); + + it("should return empty array when decrypted emails is empty string", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc(""); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve(""); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual([]); + }); + + it("should return empty array when decrypted emails is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc("something"); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve(null); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual([]); + }); + }); + + describe("Null handling for name and notes decryption", () => { + let encryptService: jest.Mocked; + let keyService: jest.Mocked; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const userId = emptyGuid as UserId; + + beforeEach(() => { + encryptService = mock(); + keyService = mock(); + encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32)); + keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); + keyService.userKey$.mockReturnValue(of(userKey)); + (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + }); + + it("should return null for name when name is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = null; + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + const view = await send.decrypt(userId); + + expect(view.name).toBeNull(); + expect(encryptService.decryptString).not.toHaveBeenCalledWith(null, expect.anything()); + }); + + it("should return null for notes when notes is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = null; + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + const view = await send.decrypt(userId); + + expect(view.notes).toBeNull(); + }); + + it("should decrypt non-null name and notes", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("Test Name"); + send.notes = mockEnc("Test Notes"); + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.name) { + return Promise.resolve("Test Name"); + } + if (encString === send.notes) { + return Promise.resolve("Test Notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.name).toBe("Test Name"); + expect(view.notes).toBe("Test Notes"); }); }); }); diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index 82c37a17528..5247d35c655 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -20,7 +20,6 @@ export class Send extends Domain { id: string; accessId: string; type: SendType; - authType: AuthType; name: EncString; notes: EncString; file: SendFile; @@ -32,9 +31,11 @@ export class Send extends Domain { expirationDate: Date; deletionDate: Date; password: string; - emails: string; + emails: EncString; + emailHashes: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(obj?: SendData) { super(); @@ -51,6 +52,7 @@ export class Send extends Domain { name: null, notes: null, key: null, + emails: null, }, ["id", "accessId"], ); @@ -60,12 +62,13 @@ export class Send extends Domain { this.maxAccessCount = obj.maxAccessCount; this.accessCount = obj.accessCount; this.password = obj.password; - this.emails = obj.emails; + this.emailHashes = obj.emailHashes; this.disabled = obj.disabled; this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null; this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null; this.expirationDate = obj.expirationDate != null ? new Date(obj.expirationDate) : null; this.hideEmail = obj.hideEmail; + this.authType = obj.authType; switch (this.type) { case SendType.Text: @@ -91,8 +94,17 @@ export class Send extends Domain { // model.key is a seed used to derive a key, not a SymmetricCryptoKey model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey); model.cryptoKey = await keyService.makeSendKey(model.key); + model.name = + this.name != null ? await encryptService.decryptString(this.name, model.cryptoKey) : null; + model.notes = + this.notes != null ? await encryptService.decryptString(this.notes, model.cryptoKey) : null; - await this.decryptObj(this, model, ["name", "notes"], model.cryptoKey); + if (this.emails != null) { + const decryptedEmails = await encryptService.decryptString(this.emails, model.cryptoKey); + model.emails = decryptedEmails ? decryptedEmails.split(",").map((e) => e.trim()) : []; + } else { + model.emails = []; + } switch (this.type) { case SendType.File: @@ -121,6 +133,7 @@ export class Send extends Domain { key: EncString.fromJSON(obj.key), name: EncString.fromJSON(obj.name), notes: EncString.fromJSON(obj.notes), + emails: EncString.fromJSON(obj.emails), text: SendText.fromJSON(obj.text), file: SendFile.fromJSON(obj.file), revisionDate, diff --git a/libs/common/src/tools/send/models/request/send.request.spec.ts b/libs/common/src/tools/send/models/request/send.request.spec.ts new file mode 100644 index 00000000000..1daee1d01ff --- /dev/null +++ b/libs/common/src/tools/send/models/request/send.request.spec.ts @@ -0,0 +1,192 @@ +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; + +import { EncString } from "../../../../key-management/crypto/models/enc-string"; +import { SendType } from "../../types/send-type"; +import { SendText } from "../domain/send-text"; + +import { SendRequest } from "./send.request"; + +describe("SendRequest", () => { + describe("constructor", () => { + it("should populate emails with encrypted string from Send.emails", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("encryptedEmailList"); + send.emailHashes = "HASH1,HASH2,HASH3"; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emails).toBe("encryptedEmailList"); + }); + + it("should populate emailHashes from Send.emailHashes", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("encryptedEmailList"); + send.emailHashes = "HASH1,HASH2,HASH3"; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emailHashes).toBe("HASH1,HASH2,HASH3"); + }); + + it("should set emails to null when Send.emails is null", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emails).toBeNull(); + expect(request.emailHashes).toBe(""); + }); + + it("should handle empty emailHashes", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emailHashes).toBe(""); + }); + + it("should not expose plaintext emails", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("2.encrypted|emaildata|here"); + send.emailHashes = "ABC123,DEF456"; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + // Ensure the request contains the encrypted string format, not plaintext + expect(request.emails).toBe("2.encrypted|emaildata|here"); + expect(request.emails).not.toContain("@"); + }); + + it("should handle name being null", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = null; + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.name).toBeNull(); + }); + + it("should handle notes being null", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = null; + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.notes).toBeNull(); + }); + + it("should include fileLength when provided for text send", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send, 1024); + + expect(request.fileLength).toBe(1024); + }); + }); + + describe("Email auth requirements", () => { + it("should create request with encrypted emails and plaintext emailHashes", () => { + // Setup: A Send with encrypted emails and computed hashes + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("2.encryptedEmailString|data"); + send.emailHashes = "A1B2C3D4,E5F6G7H8"; // Plaintext hashes + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + // Act: Create the request + const request = new SendRequest(send); + + // emails field contains encrypted value + expect(request.emails).toBe("2.encryptedEmailString|data"); + expect(request.emails).toContain("encrypted"); + + //emailHashes field contains plaintext comma-separated hashes + expect(request.emailHashes).toBe("A1B2C3D4,E5F6G7H8"); + expect(request.emailHashes).not.toContain("encrypted"); + expect(request.emailHashes.split(",")).toHaveLength(2); + }); + }); +}); 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 902ca0a2c54..37590e40108 100644 --- a/libs/common/src/tools/send/models/request/send.request.ts +++ b/libs/common/src/tools/send/models/request/send.request.ts @@ -18,6 +18,7 @@ export class SendRequest { file: SendFileApi; password: string; emails: string; + emailHashes: string; disabled: boolean; hideEmail: boolean; @@ -31,7 +32,8 @@ export class SendRequest { this.deletionDate = send.deletionDate != null ? send.deletionDate.toISOString() : null; this.key = send.key != null ? send.key.encryptedString : null; this.password = send.password; - this.emails = send.emails; + this.emails = send.emails ? send.emails.encryptedString : null; + this.emailHashes = send.emailHashes; this.disabled = send.disabled; this.hideEmail = send.hideEmail; 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 7a7885d5ae1..a51b1e8ac7a 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -1,8 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; + import { BaseResponse } from "../../../../models/response/base.response"; -import { AuthType } from "../../types/auth-type"; -import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; @@ -10,7 +11,6 @@ export class SendResponse extends BaseResponse { id: string; accessId: string; type: SendType; - authType: AuthType; name: string; notes: string; file: SendFileApi; @@ -25,6 +25,7 @@ export class SendResponse extends BaseResponse { emails: string; disable: boolean; hideEmail: boolean; + authType: AuthType; constructor(response: any) { super(response); @@ -44,6 +45,7 @@ export class SendResponse extends BaseResponse { this.emails = this.getResponseProperty("Emails"); this.disable = this.getResponseProperty("Disabled") || false; this.hideEmail = this.getResponseProperty("HideEmail") || false; + this.authType = this.getResponseProperty("AuthType"); const text = this.getResponseProperty("Text"); if (text != null) { 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 d07de6d8293..150a649671b 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -19,7 +19,6 @@ export class SendView implements View { key: Uint8Array; cryptoKey: SymmetricCryptoKey; type: SendType = null; - authType: AuthType = null; text = new SendTextView(); file = new SendFileView(); maxAccessCount?: number = null; @@ -31,6 +30,7 @@ export class SendView implements View { emails: string[] = []; disabled = false; hideEmail = false; + authType: AuthType = null; constructor(s?: Send) { if (!s) { @@ -49,6 +49,7 @@ export class SendView implements View { this.disabled = s.disabled; this.password = s.password; this.hideEmail = s.hideEmail; + this.authType = s.authType; } get urlB64Key(): string { diff --git a/libs/common/src/tools/send/services/send-api.service.abstraction.ts b/libs/common/src/tools/send/services/send-api.service.abstraction.ts index 80c4410af11..a7e36d8c8b1 100644 --- a/libs/common/src/tools/send/services/send-api.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send-api.service.abstraction.ts @@ -1,3 +1,5 @@ +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; + import { ListResponse } from "../../../models/response/list.response"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { Send } from "../models/domain/send"; @@ -16,6 +18,10 @@ export abstract class SendApiService { request: SendAccessRequest, apiUrl?: string, ): Promise; + abstract postSendAccessV2( + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise; abstract getSends(): Promise>; abstract postSend(request: SendRequest): Promise; abstract postFileTypeSend(request: SendRequest): Promise; @@ -28,6 +34,11 @@ export abstract class SendApiService { request: SendAccessRequest, apiUrl?: string, ): Promise; + abstract getSendFileDownloadDataV2( + send: SendAccessView, + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise; abstract renewSendFileUploadUrl( sendId: string, fileId: string, 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 1c931b7ad98..57004b6ff0e 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -1,3 +1,5 @@ +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; + import { ApiService } from "../../../abstractions/api.service"; import { ErrorResponse } from "../../../models/response/error.response"; import { ListResponse } from "../../../models/response/list.response"; @@ -52,6 +54,25 @@ export class SendApiService implements SendApiServiceAbstraction { return new SendAccessResponse(r); } + async postSendAccessV2( + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise { + const setAuthTokenHeader = (headers: Headers) => { + headers.set("Authorization", "Bearer " + accessToken.token); + }; + const r = await this.apiService.send( + "POST", + "/sends/access", + null, + false, + true, + apiUrl, + setAuthTokenHeader, + ); + return new SendAccessResponse(r); + } + async getSendFileDownloadData( send: SendAccessView, request: SendAccessRequest, @@ -72,6 +93,26 @@ export class SendApiService implements SendApiServiceAbstraction { return new SendFileDownloadDataResponse(r); } + async getSendFileDownloadDataV2( + send: SendAccessView, + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise { + const setAuthTokenHeader = (headers: Headers) => { + headers.set("Authorization", "Bearer " + accessToken.token); + }; + const r = await this.apiService.send( + "POST", + "/sends/access/file/" + send.file.id, + null, + true, + true, + apiUrl, + setAuthTokenHeader, + ); + return new SendFileDownloadDataResponse(r); + } + async getSends(): Promise> { const r = await this.apiService.send("GET", "/sends", null, true, true); return new ListResponse(r, SendResponse); @@ -148,6 +189,7 @@ export class SendApiService implements SendApiServiceAbstraction { private async upload(sendData: [Send, EncArrayBuffer]): Promise { const request = new SendRequest(sendData[0], sendData[1]?.buffer.byteLength); + let response: SendResponse; if (sendData[0].id == null) { if (sendData[0].type === SendType.Text) { 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 fb99ddbe3bc..1c587327098 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; @@ -16,6 +17,7 @@ import { import { KeyGenerationService } from "../../../key-management/crypto"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { EnvironmentService } from "../../../platform/abstractions/environment.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { Utils } from "../../../platform/misc/utils"; @@ -29,6 +31,7 @@ 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 { SendTextView } from "../models/view/send-text.view"; import { SendView } from "../models/view/send.view"; import { SendType } from "../types/send-type"; @@ -48,7 +51,8 @@ describe("SendService", () => { const keyGenerationService = mock(); const encryptService = mock(); const environmentService = mock(); - + const cryptoFunctionService = mock(); + const configService = mock(); let sendStateProvider: SendStateProvider; let sendService: SendService; @@ -94,6 +98,8 @@ describe("SendService", () => { keyGenerationService, sendStateProvider, encryptService, + cryptoFunctionService, + configService, ); }); @@ -573,4 +579,256 @@ describe("SendService", () => { expect(sendsAfterDelete.length).toBe(0); }); }); + + describe("encrypt", () => { + let sendView: SendView; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const mockCryptoKey = new SymmetricCryptoKey(new Uint8Array(32)); + + beforeEach(() => { + sendView = new SendView(); + sendView.id = "sendId"; + sendView.type = SendType.Text; + sendView.name = "Test Send"; + sendView.notes = "Test Notes"; + const sendTextView = new SendTextView(); + sendTextView.text = "test text"; + sendTextView.hidden = false; + sendView.text = sendTextView; + sendView.key = new Uint8Array(16); + sendView.cryptoKey = mockCryptoKey; + sendView.maxAccessCount = 5; + sendView.disabled = false; + sendView.hideEmail = false; + sendView.deletionDate = new Date("2024-12-31"); + sendView.expirationDate = null; + + keyService.userKey$.mockReturnValue(of(userKey)); + keyService.makeSendKey.mockResolvedValue(mockCryptoKey); + encryptService.encryptBytes.mockResolvedValue({ encryptedString: "encryptedKey" } as any); + encryptService.encryptString.mockResolvedValue({ encryptedString: "encrypted" } as any); + }); + + describe("when SendEmailOTP feature flag is ON", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(true); + cryptoFunctionService.hash.mockClear(); + }); + + describe("email encryption", () => { + it("should encrypt emails when email list is provided", async () => { + sendView.emails = ["test@example.com", "user@test.com"]; + cryptoFunctionService.hash.mockResolvedValue(new Uint8Array([0xab, 0xcd])); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(encryptService.encryptString).toHaveBeenCalledWith( + "test@example.com,user@test.com", + mockCryptoKey, + ); + expect(send.emails).toEqual({ encryptedString: "encrypted" }); + expect(send.password).toBeNull(); + }); + + it("should set emails to null when email list is empty", async () => { + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + }); + + it("should set emails to null when email list is null", async () => { + sendView.emails = null; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + }); + + it("should set emails to null when email list is undefined", async () => { + sendView.emails = undefined; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + }); + }); + + describe("email hashing", () => { + it("should hash emails using SHA-256 and return uppercase hex", async () => { + sendView.emails = ["test@example.com"]; + const mockHash = new Uint8Array([0xab, 0xcd, 0xef]); + + cryptoFunctionService.hash.mockResolvedValue(mockHash); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256"); + expect(send.emailHashes).toBe("ABCDEF"); + }); + + it("should hash multiple emails and return comma-separated hashes", async () => { + sendView.emails = ["test@example.com", "user@test.com"]; + const mockHash1 = new Uint8Array([0xab, 0xcd]); + const mockHash2 = new Uint8Array([0x12, 0x34]); + + cryptoFunctionService.hash + .mockResolvedValueOnce(mockHash1) + .mockResolvedValueOnce(mockHash2); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256"); + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256"); + expect(send.emailHashes).toBe("ABCD,1234"); + }); + + it("should trim and lowercase emails before hashing", async () => { + sendView.emails = [" Test@Example.COM ", "USER@test.com"]; + const mockHash = new Uint8Array([0xff]); + + cryptoFunctionService.hash.mockResolvedValue(mockHash); + + await sendService.encrypt(sendView, null, null); + + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256"); + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256"); + }); + + it("should set emailHashes to empty string when no emails", async () => { + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emailHashes).toBe(""); + expect(cryptoFunctionService.hash).not.toHaveBeenCalled(); + }); + + it("should handle single email correctly", async () => { + sendView.emails = ["single@test.com"]; + const mockHash = new Uint8Array([0xa1, 0xb2, 0xc3]); + + cryptoFunctionService.hash.mockResolvedValue(mockHash); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emailHashes).toBe("A1B2C3"); + }); + }); + + describe("emails and password mutual exclusivity", () => { + it("should set password to null when emails are provided", async () => { + sendView.emails = ["test@example.com"]; + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeDefined(); + expect(send.password).toBeNull(); + }); + + it("should set password when no emails are provided", async () => { + sendView.emails = []; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue({ + keyB64: "hashedPassword", + } as any); + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeNull(); + expect(send.password).toBe("hashedPassword"); + }); + }); + }); + + describe("when SendEmailOTP feature flag is OFF", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(false); + cryptoFunctionService.hash.mockClear(); + }); + + it("should NOT encrypt emails even when provided", async () => { + sendView.emails = ["test@example.com"]; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(cryptoFunctionService.hash).not.toHaveBeenCalled(); + }); + + it("should use password when provided and flag is OFF", async () => { + sendView.emails = []; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue({ + keyB64: "hashedPassword", + } as any); + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(send.password).toBe("hashedPassword"); + }); + + it("should ignore emails and use password when both provided", async () => { + sendView.emails = ["test@example.com"]; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue({ + keyB64: "hashedPassword", + } as any); + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(send.password).toBe("hashedPassword"); + expect(cryptoFunctionService.hash).not.toHaveBeenCalled(); + }); + + it("should set emails and password to null when neither provided", async () => { + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(send.password).toBeUndefined(); + }); + }); + + describe("null handling for name and notes", () => { + it("should handle null name correctly", async () => { + sendView.name = null; + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.name).toBeNull(); + }); + + it("should handle null notes correctly", async () => { + sendView.notes = null; + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.notes).toBeNull(); + }); + + it("should encrypt non-null name and notes", async () => { + sendView.name = "Test Name"; + sendView.notes = "Test Notes"; + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(encryptService.encryptString).toHaveBeenCalledWith("Test Name", mockCryptoKey); + expect(encryptService.encryptString).toHaveBeenCalledWith("Test Notes", mockCryptoKey); + expect(send.name).toEqual({ encryptedString: "encrypted" }); + expect(send.notes).toEqual({ encryptedString: "encrypted" }); + }); + }); + }); }); diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index c274d90146e..078e94b2563 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -7,9 +7,12 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv // eslint-disable-next-line no-restricted-imports import { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management"; +import { FeatureFlag } from "../../../enums/feature-flag.enum"; import { KeyGenerationService } from "../../../key-management/crypto"; +import { CryptoFunctionService } from "../../../key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { Utils } from "../../../platform/misc/utils"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; @@ -51,6 +54,8 @@ export class SendService implements InternalSendServiceAbstraction { private keyGenerationService: KeyGenerationService, private stateProvider: SendStateProvider, private encryptService: EncryptService, + private cryptoFunctionService: CryptoFunctionService, + private configService: ConfigService, ) {} async encrypt( @@ -80,19 +85,30 @@ export class SendService implements InternalSendServiceAbstraction { model.cryptoKey = key.derivedKey; } + // Check feature flag for email OTP authentication + const sendEmailOTPEnabled = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + const hasEmails = (model.emails?.length ?? 0) > 0; - if (hasEmails) { - send.emails = model.emails.join(","); + + if (sendEmailOTPEnabled && hasEmails) { + const plaintextEmails = model.emails.join(","); + send.emails = await this.encryptService.encryptString(plaintextEmails, model.cryptoKey); + send.emailHashes = await this.hashEmails(plaintextEmails); send.password = null; - } else if (password != null) { - // Note: Despite being called key, the passwordKey is not used for encryption. - // It is used as a static proof that the client knows the password, and has the encryption key. - const passwordKey = await this.keyGenerationService.deriveKeyFromPassword( - password, - model.key, - new PBKDF2KdfConfig(SEND_KDF_ITERATIONS), - ); - send.password = passwordKey.keyB64; + } else { + send.emails = null; + send.emailHashes = ""; + + if (password != null) { + // Note: Despite being called key, the passwordKey is not used for encryption. + // It is used as a static proof that the client knows the password, and has the encryption key. + const passwordKey = await this.keyGenerationService.deriveKeyFromPassword( + password, + model.key, + new PBKDF2KdfConfig(SEND_KDF_ITERATIONS), + ); + send.password = passwordKey.keyB64; + } } const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; if (userKey == null) { @@ -100,10 +116,14 @@ export class SendService implements InternalSendServiceAbstraction { } // Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey send.key = await this.encryptService.encryptBytes(model.key, userKey); - // FIXME: model.name can be null. encryptString should not be called with null values. - send.name = await this.encryptService.encryptString(model.name, model.cryptoKey); - // FIXME: model.notes can be null. encryptString should not be called with null values. - send.notes = await this.encryptService.encryptString(model.notes, model.cryptoKey); + send.name = + model.name != null + ? await this.encryptService.encryptString(model.name, model.cryptoKey) + : null; + send.notes = + model.notes != null + ? await this.encryptService.encryptString(model.notes, model.cryptoKey) + : null; if (send.type === SendType.Text) { send.text = new SendText(); // FIXME: model.text.text can be null. encryptString should not be called with null values. @@ -127,6 +147,8 @@ export class SendService implements InternalSendServiceAbstraction { } } + send.authType = model.authType; + return [send, fileData]; } @@ -371,4 +393,19 @@ export class SendService implements InternalSendServiceAbstraction { decryptedSends.sort(Utils.getSortFunction(this.i18nService, "name")); return decryptedSends; } + + private async hashEmails(emails: string): Promise { + if (!emails) { + return ""; + } + + const emailArray = emails.split(",").map((e) => e.trim().toLowerCase()); + const hashPromises = emailArray.map(async (email) => { + const hash: Uint8Array = await this.cryptoFunctionService.hash(email, "sha256"); + return Utils.fromBufferToHex(hash).toUpperCase(); + }); + + const hashes = await Promise.all(hashPromises); + return hashes.join(","); + } } 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 c1d04ab2926..9c4e121edc0 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 @@ -20,6 +20,7 @@ export function testSendViewData(id: string, name: string) { data.deletionDate = null; data.notes = "Notes!!"; data.key = null; + data.emails = []; return data; } @@ -39,6 +40,8 @@ export function createSendData(value: Partial = {}) { expirationDate: "2024-09-04", deletionDate: "2024-09-04", password: "password", + emails: "", + emailHashes: "", disabled: false, hideEmail: false, }; @@ -62,6 +65,8 @@ export function testSendData(id: string, name: string) { data.deletionDate = null; data.notes = "Notes!!"; data.key = null; + data.emails = ""; + data.emailHashes = ""; return data; } @@ -77,5 +82,7 @@ export function testSend(id: string, name: string) { data.deletionDate = null; data.notes = new EncString("Notes!!"); data.key = null; + data.emails = null; + data.emailHashes = ""; return data; } diff --git a/libs/common/src/vault/abstractions/cipher-sdk.service.ts b/libs/common/src/vault/abstractions/cipher-sdk.service.ts new file mode 100644 index 00000000000..3101531eda6 --- /dev/null +++ b/libs/common/src/vault/abstractions/cipher-sdk.service.ts @@ -0,0 +1,109 @@ +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +/** + * Service responsible for cipher operations using the SDK. + */ +export abstract class CipherSdkService { + /** + * Creates a new cipher on the server using the SDK. + * + * @param cipherView The cipher view to create + * @param userId The user ID to use for SDK client + * @param orgAdmin Whether this is an organization admin operation + * @returns A promise that resolves to the created cipher view + */ + abstract createWithServer( + cipherView: CipherView, + userId: UserId, + orgAdmin?: boolean, + ): Promise; + + /** + * Updates a cipher on the server using the SDK. + * + * @param cipher The cipher view to update + * @param userId The user ID to use for SDK client + * @param originalCipherView The original cipher view before changes (optional, used for admin operations) + * @param orgAdmin Whether this is an organization admin operation + * @returns A promise that resolves to the updated cipher view + */ + abstract updateWithServer( + cipher: CipherView, + userId: UserId, + originalCipherView?: CipherView, + orgAdmin?: boolean, + ): Promise; + + /** + * Deletes a cipher on the server using the SDK. + * + * @param id The cipher ID to delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @returns A promise that resolves when the cipher is deleted + */ + abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + + /** + * Deletes multiple ciphers on the server using the SDK. + * + * @param ids The cipher IDs to delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @param orgId The organization ID (required when asAdmin is true) + * @returns A promise that resolves when the ciphers are deleted + */ + abstract deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; + + /** + * Soft deletes a cipher on the server using the SDK. + * + * @param id The cipher ID to soft delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @returns A promise that resolves when the cipher is soft deleted + */ + abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + + /** + * Soft deletes multiple ciphers on the server using the SDK. + * + * @param ids The cipher IDs to soft delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @param orgId The organization ID (required when asAdmin is true) + * @returns A promise that resolves when the ciphers are soft deleted + */ + abstract softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; + + /** + * Restores a soft-deleted cipher on the server using the SDK. + * + * @param id The cipher ID to restore + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @returns A promise that resolves when the cipher is restored + */ + abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + + /** + * Restores multiple soft-deleted ciphers on the server using the SDK. + * + * @param ids The cipher IDs to restore + * @param userId The user ID to use for SDK client + * @param orgId The organization ID (determines whether to use admin API) + * @returns A promise that resolves when the ciphers are restored + */ + abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise; +} diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 203984075f7..4b544b2a34e 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -119,9 +119,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + ): Promise; + /** * Update a cipher with the server * @param cipher The cipher to update @@ -131,10 +133,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + ): Promise; /** * Move a cipher to an organization by re-encrypting its keys with the organization's key. @@ -227,8 +230,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider; abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise; abstract delete(id: string | string[], userId: UserId): Promise; - abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; - abstract deleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise; + abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + abstract deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; abstract deleteAttachment( id: string, revisionDate: string, @@ -244,14 +252,19 @@ export abstract class CipherService implements UserKeyRotationDataProvider number; - abstract softDelete(id: string | string[], userId: UserId): Promise; - abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; - abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise; + abstract softDelete(id: string | string[], userId: UserId): Promise; + abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + abstract softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; abstract restore( cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[], userId: UserId, - ): Promise; - abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + ): Promise; + abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise; abstract getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise; abstract setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId): Promise; @@ -272,7 +285,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider; /** - * Decrypts a cipher using either the SDK or the legacy method based on the feature flag. + * Decrypts a cipher using either the use-sdk-cipheroperationsSDK or the legacy method based on the feature flag. * @param cipher The cipher to decrypt. * @param userId The user ID to use for decryption. * @returns A promise that resolves to the decrypted cipher view. diff --git a/libs/common/src/vault/models/view/cipher.view.spec.ts b/libs/common/src/vault/models/view/cipher.view.spec.ts index 475fe9e23f3..1c7017d5d89 100644 --- a/libs/common/src/vault/models/view/cipher.view.spec.ts +++ b/libs/common/src/vault/models/view/cipher.view.spec.ts @@ -353,4 +353,366 @@ describe("CipherView", () => { }); }); }); + + // Note: These tests use jest.requireActual() because the file has jest.mock() calls + // at the top that mock LoginView, FieldView, etc. Those mocks are needed for other tests + // but interfere with these tests which need the real implementations. + describe("toSdkCreateCipherRequest", () => { + it("maps all properties correctly for a login cipher", () => { + const { FieldView: RealFieldView } = jest.requireActual("./field.view"); + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c"; + cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f"; + cipherView.collectionIds = ["b0473506-3c3c-4260-a734-dfaaf833ab6f"]; + cipherView.name = "Test Login"; + cipherView.notes = "Test notes"; + cipherView.type = CipherType.Login; + cipherView.favorite = true; + cipherView.reprompt = CipherRepromptType.Password; + + const field = new RealFieldView(); + field.name = "testField"; + field.value = "testValue"; + field.type = SdkFieldType.Text; + cipherView.fields = [field]; + + cipherView.login = new RealLoginView(); + cipherView.login.username = "testuser"; + cipherView.login.password = "testpass"; + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c")); + expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f")); + expect(result.collectionIds).toEqual([asUuid("b0473506-3c3c-4260-a734-dfaaf833ab6f")]); + expect(result.name).toBe("Test Login"); + expect(result.notes).toBe("Test notes"); + expect(result.favorite).toBe(true); + expect(result.reprompt).toBe(CipherRepromptType.Password); + expect(result.fields).toHaveLength(1); + expect(result.fields![0]).toMatchObject({ + name: "testField", + value: "testValue", + type: SdkFieldType.Text, + }); + expect(result.type).toHaveProperty("login"); + expect((result.type as any).login).toMatchObject({ + username: "testuser", + password: "testpass", + }); + }); + + it("handles undefined organizationId and folderId", () => { + const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.SecureNote; + cipherView.secureNote = new RealSecureNoteView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.organizationId).toBeUndefined(); + expect(result.folderId).toBeUndefined(); + expect(result.name).toBe("Test Cipher"); + }); + + it("handles empty collectionIds array", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.collectionIds = []; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.collectionIds).toEqual([]); + }); + + it("defaults favorite to false when undefined", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.favorite = undefined as any; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.favorite).toBe(false); + }); + + it("defaults reprompt to None when undefined", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.reprompt = undefined as any; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.reprompt).toBe(CipherRepromptType.None); + }); + + test.each([ + ["Login", CipherType.Login, "login.view", "LoginView"], + ["Card", CipherType.Card, "card.view", "CardView"], + ["Identity", CipherType.Identity, "identity.view", "IdentityView"], + ["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"], + ["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"], + ])( + "creates correct type property for %s cipher", + (typeName: string, cipherType: CipherType, moduleName: string, className: string) => { + const module = jest.requireActual(`./${moduleName}`); + const ViewClass = module[className]; + + const cipherView = new CipherView(); + cipherView.name = `Test ${typeName}`; + cipherView.type = cipherType; + + // Set the appropriate view property + const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1); + (cipherView as any)[viewPropertyName] = new ViewClass(); + + const result = cipherView.toSdkCreateCipherRequest(); + + const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1); + expect(result.type).toHaveProperty(typeKey); + }, + ); + }); + + describe("toSdkUpdateCipherRequest", () => { + it("maps all properties correctly for an update request", () => { + const { FieldView: RealFieldView } = jest.requireActual("./field.view"); + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c"; + cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f"; + cipherView.name = "Updated Login"; + cipherView.notes = "Updated notes"; + cipherView.type = CipherType.Login; + cipherView.favorite = true; + cipherView.reprompt = CipherRepromptType.Password; + cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z"); + cipherView.archivedDate = new Date("2022-01-03T12:00:00.000Z"); + cipherView.key = new EncString("cipher-key"); + + const mockField = new RealFieldView(); + mockField.name = "testField"; + mockField.value = "testValue"; + cipherView.fields = [mockField]; + + cipherView.login = new RealLoginView(); + cipherView.login.username = "testuser"; + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.id).toEqual(asUuid("0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602")); + expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c")); + expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f")); + expect(result.name).toBe("Updated Login"); + expect(result.notes).toBe("Updated notes"); + expect(result.favorite).toBe(true); + expect(result.reprompt).toBe(CipherRepromptType.Password); + expect(result.revisionDate).toBe("2022-01-02T12:00:00.000Z"); + expect(result.archivedDate).toBe("2022-01-03T12:00:00.000Z"); + expect(result.fields).toHaveLength(1); + expect(result.fields![0]).toMatchObject({ + name: "testField", + value: "testValue", + }); + expect(result.type).toHaveProperty("login"); + expect((result.type as any).login).toMatchObject({ + username: "testuser", + }); + expect(result.key).toBeDefined(); + }); + + it("handles undefined optional properties", () => { + const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.SecureNote; + cipherView.secureNote = new RealSecureNoteView(); + cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z"); + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.organizationId).toBeUndefined(); + expect(result.folderId).toBeUndefined(); + expect(result.archivedDate).toBeUndefined(); + expect(result.key).toBeUndefined(); + }); + + it("converts dates to ISO strings", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + cipherView.revisionDate = new Date("2022-05-15T10:30:00.000Z"); + cipherView.archivedDate = new Date("2022-06-20T14:45:00.000Z"); + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.revisionDate).toBe("2022-05-15T10:30:00.000Z"); + expect(result.archivedDate).toBe("2022-06-20T14:45:00.000Z"); + }); + + it("includes attachments when present", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + const { AttachmentView: RealAttachmentView } = jest.requireActual("./attachment.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const attachment1 = new RealAttachmentView(); + attachment1.id = "attachment-id-1"; + attachment1.fileName = "file1.txt"; + + const attachment2 = new RealAttachmentView(); + attachment2.id = "attachment-id-2"; + attachment2.fileName = "file2.pdf"; + + cipherView.attachments = [attachment1, attachment2]; + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.attachments).toHaveLength(2); + }); + + test.each([ + ["Login", CipherType.Login, "login.view", "LoginView"], + ["Card", CipherType.Card, "card.view", "CardView"], + ["Identity", CipherType.Identity, "identity.view", "IdentityView"], + ["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"], + ["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"], + ])( + "creates correct type property for %s cipher", + (typeName: string, cipherType: CipherType, moduleName: string, className: string) => { + const module = jest.requireActual(`./${moduleName}`); + const ViewClass = module[className]; + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = `Test ${typeName}`; + cipherView.type = cipherType; + + // Set the appropriate view property + const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1); + (cipherView as any)[viewPropertyName] = new ViewClass(); + + const result = cipherView.toSdkUpdateCipherRequest(); + + const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1); + expect(result.type).toHaveProperty(typeKey); + }, + ); + }); + + describe("getSdkCipherViewType", () => { + it("returns login type for Login cipher", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + cipherView.login.username = "testuser"; + cipherView.login.password = "testpass"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("login"); + expect((result as any).login).toMatchObject({ + username: "testuser", + password: "testpass", + }); + }); + + it("returns card type for Card cipher", () => { + const { CardView: RealCardView } = jest.requireActual("./card.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.Card; + cipherView.card = new RealCardView(); + cipherView.card.cardholderName = "John Doe"; + cipherView.card.number = "4111111111111111"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("card"); + expect((result as any).card.cardholderName).toBe("John Doe"); + expect((result as any).card.number).toBe("4111111111111111"); + }); + + it("returns identity type for Identity cipher", () => { + const { IdentityView: RealIdentityView } = jest.requireActual("./identity.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.Identity; + cipherView.identity = new RealIdentityView(); + cipherView.identity.firstName = "John"; + cipherView.identity.lastName = "Doe"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("identity"); + expect((result as any).identity.firstName).toBe("John"); + expect((result as any).identity.lastName).toBe("Doe"); + }); + + it("returns secureNote type for SecureNote cipher", () => { + const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.SecureNote; + cipherView.secureNote = new RealSecureNoteView(); + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("secureNote"); + }); + + it("returns sshKey type for SshKey cipher", () => { + const { SshKeyView: RealSshKeyView } = jest.requireActual("./ssh-key.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.SshKey; + cipherView.sshKey = new RealSshKeyView(); + cipherView.sshKey.privateKey = "privateKeyData"; + cipherView.sshKey.publicKey = "publicKeyData"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("sshKey"); + expect((result as any).sshKey.privateKey).toBe("privateKeyData"); + expect((result as any).sshKey.publicKey).toBe("publicKeyData"); + }); + + it("defaults to empty login for unknown cipher type", () => { + const cipherView = new CipherView(); + cipherView.type = 999 as CipherType; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("login"); + }); + }); }); diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 89f59665681..0909d0bda80 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -1,7 +1,12 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { asUuid, uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { ItemView } from "@bitwarden/common/vault/models/view/item.view"; -import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; +import { + CipherCreateRequest, + CipherEditRequest, + CipherViewType, + CipherView as SdkCipherView, +} from "@bitwarden/sdk-internal"; import { View } from "../../../models/view/view"; import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface"; @@ -332,6 +337,85 @@ export class CipherView implements View, InitializerMetadata { return cipherView; } + /** + * Maps CipherView to an SDK CipherCreateRequest + * + * @returns {CipherCreateRequest} The SDK cipher create request object + */ + toSdkCreateCipherRequest(): CipherCreateRequest { + const sdkCipherCreateRequest: CipherCreateRequest = { + organizationId: this.organizationId ? asUuid(this.organizationId) : undefined, + collectionIds: this.collectionIds ? this.collectionIds.map((i) => asUuid(i)) : [], + folderId: this.folderId ? asUuid(this.folderId) : undefined, + name: this.name ?? "", + notes: this.notes, + favorite: this.favorite ?? false, + reprompt: this.reprompt ?? CipherRepromptType.None, + fields: this.fields?.map((f) => f.toSdkFieldView()), + type: this.getSdkCipherViewType(), + }; + + return sdkCipherCreateRequest; + } + + /** + * Maps CipherView to an SDK CipherEditRequest + * + * @returns {CipherEditRequest} The SDK cipher edit request object + */ + toSdkUpdateCipherRequest(): CipherEditRequest { + const sdkCipherEditRequest: CipherEditRequest = { + id: asUuid(this.id), + organizationId: this.organizationId ? asUuid(this.organizationId) : undefined, + folderId: this.folderId ? asUuid(this.folderId) : undefined, + name: this.name ?? "", + notes: this.notes, + favorite: this.favorite ?? false, + reprompt: this.reprompt ?? CipherRepromptType.None, + fields: this.fields?.map((f) => f.toSdkFieldView()), + type: this.getSdkCipherViewType(), + revisionDate: this.revisionDate?.toISOString(), + archivedDate: this.archivedDate?.toISOString(), + attachments: this.attachments?.map((a) => a.toSdkAttachmentView()), + key: this.key?.toSdk(), + }; + + return sdkCipherEditRequest; + } + + /** + * Returns the SDK CipherViewType object for the cipher. + * + * @returns {CipherViewType} The SDK CipherViewType for the cipher.t + */ + getSdkCipherViewType(): CipherViewType { + let viewType: CipherViewType; + switch (this.type) { + case CipherType.Card: + viewType = { card: this.card?.toSdkCardView() }; + break; + case CipherType.Identity: + viewType = { identity: this.identity?.toSdkIdentityView() }; + break; + case CipherType.Login: + viewType = { login: this.login?.toSdkLoginView() }; + break; + case CipherType.SecureNote: + viewType = { secureNote: this.secureNote?.toSdkSecureNoteView() }; + break; + case CipherType.SshKey: + viewType = { sshKey: this.sshKey?.toSdkSshKeyView() }; + break; + default: + viewType = { + // Default to empty login - should not be valid code path. + login: new LoginView().toSdkLoginView(), + }; + break; + } + return viewType; + } + /** * Maps CipherView to SdkCipherView * diff --git a/libs/common/src/vault/services/cipher-sdk.service.spec.ts b/libs/common/src/vault/services/cipher-sdk.service.spec.ts new file mode 100644 index 00000000000..cb21ff28133 --- /dev/null +++ b/libs/common/src/vault/services/cipher-sdk.service.spec.ts @@ -0,0 +1,534 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { UserId, CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { CipherType } from "../enums/cipher-type"; + +import { DefaultCipherSdkService } from "./cipher-sdk.service"; + +describe("DefaultCipherSdkService", () => { + const sdkService = mock(); + const logService = mock(); + const userId = "test-user-id" as UserId; + const cipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId; + + let cipherSdkService: DefaultCipherSdkService; + let mockSdkClient: any; + let mockCiphersSdk: any; + let mockAdminSdk: any; + let mockVaultSdk: any; + + beforeEach(() => { + // Mock the SDK client chain for admin operations + mockAdminSdk = { + create: jest.fn(), + edit: jest.fn(), + delete: jest.fn().mockResolvedValue(undefined), + delete_many: jest.fn().mockResolvedValue(undefined), + soft_delete: jest.fn().mockResolvedValue(undefined), + soft_delete_many: jest.fn().mockResolvedValue(undefined), + restore: jest.fn().mockResolvedValue(undefined), + restore_many: jest.fn().mockResolvedValue(undefined), + }; + mockCiphersSdk = { + create: jest.fn(), + edit: jest.fn(), + delete: jest.fn().mockResolvedValue(undefined), + delete_many: jest.fn().mockResolvedValue(undefined), + soft_delete: jest.fn().mockResolvedValue(undefined), + soft_delete_many: jest.fn().mockResolvedValue(undefined), + restore: jest.fn().mockResolvedValue(undefined), + restore_many: jest.fn().mockResolvedValue(undefined), + admin: jest.fn().mockReturnValue(mockAdminSdk), + }; + mockVaultSdk = { + ciphers: jest.fn().mockReturnValue(mockCiphersSdk), + }; + const mockSdkValue = { + vault: jest.fn().mockReturnValue(mockVaultSdk), + }; + mockSdkClient = { + take: jest.fn().mockReturnValue({ + value: mockSdkValue, + [Symbol.dispose]: jest.fn(), + }), + }; + + // Mock sdkService to return the mock client + sdkService.userClient$.mockReturnValue(of(mockSdkClient)); + + cipherSdkService = new DefaultCipherSdkService(sdkService, logService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("createWithServer()", () => { + it("should create cipher using SDK when orgAdmin is false", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Test Cipher"; + cipherView.organizationId = orgId; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockCiphersSdk.create.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.createWithServer(cipherView, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: cipherView.name, + organizationId: expect.anything(), + }), + ); + expect(result).toBeInstanceOf(CipherView); + expect(result?.name).toBe(cipherView.name); + }); + + it("should create cipher using SDK admin API when orgAdmin is true", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Test Admin Cipher"; + cipherView.organizationId = orgId; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockAdminSdk.create.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.createWithServer(cipherView, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: cipherView.name, + }), + ); + expect(result).toBeInstanceOf(CipherView); + expect(result?.name).toBe(cipherView.name); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + + await expect(cipherSdkService.createWithServer(cipherView, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to create cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + + mockCiphersSdk.create.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.createWithServer(cipherView, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to create cipher"), + ); + }); + }); + + describe("updateWithServer()", () => { + it("should update cipher using SDK when orgAdmin is false", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Updated Cipher"; + cipherView.organizationId = orgId; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockCiphersSdk.edit.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.updateWithServer(cipherView, userId, undefined, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.edit).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.anything(), + name: cipherView.name, + }), + ); + expect(result).toBeInstanceOf(CipherView); + expect(result.name).toBe(cipherView.name); + }); + + it("should update cipher using SDK admin API when orgAdmin is true", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Updated Admin Cipher"; + cipherView.organizationId = orgId; + + const originalCipherView = new CipherView(); + originalCipherView.id = cipherId; + originalCipherView.name = "Original Cipher"; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.updateWithServer( + cipherView, + userId, + originalCipherView, + true, + ); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.edit).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.anything(), + name: cipherView.name, + }), + originalCipherView.toSdkCipherView(), + ); + expect(result).toBeInstanceOf(CipherView); + expect(result.name).toBe(cipherView.name); + }); + + it("should update cipher using SDK admin API without originalCipherView", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Updated Admin Cipher"; + cipherView.organizationId = orgId; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.updateWithServer(cipherView, userId, undefined, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.edit).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.anything(), + name: cipherView.name, + }), + expect.anything(), // Empty CipherView - timestamps vary so we just verify it was called + ); + expect(result).toBeInstanceOf(CipherView); + expect(result.name).toBe(cipherView.name); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + + await expect( + cipherSdkService.updateWithServer(cipherView, userId, undefined, false), + ).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to update cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + + mockCiphersSdk.edit.mockRejectedValue(new Error("SDK error")); + + await expect( + cipherSdkService.updateWithServer(cipherView, userId, undefined, false), + ).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to update cipher"), + ); + }); + }); + + describe("deleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should delete cipher using SDK when asAdmin is false", async () => { + await cipherSdkService.deleteWithServer(testCipherId, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.delete).toHaveBeenCalledWith(testCipherId); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should delete cipher using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.deleteWithServer(testCipherId, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.delete).toHaveBeenCalledWith(testCipherId); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.delete.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete cipher"), + ); + }); + }); + + describe("deleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should delete multiple ciphers using SDK when asAdmin is false", async () => { + await cipherSdkService.deleteManyWithServer(testCipherIds, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.delete_many).toHaveBeenCalledWith(testCipherIds); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should delete multiple ciphers using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.delete_many).toHaveBeenCalledWith(testCipherIds, orgId); + }); + + it("should throw error when asAdmin is true but orgId is missing", async () => { + await expect( + cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, undefined), + ).rejects.toThrow("Organization ID is required for admin delete."); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete multiple ciphers"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.delete_many.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete multiple ciphers"), + ); + }); + }); + + describe("softDeleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should soft delete cipher using SDK when asAdmin is false", async () => { + await cipherSdkService.softDeleteWithServer(testCipherId, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.soft_delete).toHaveBeenCalledWith(testCipherId); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should soft delete cipher using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.softDeleteWithServer(testCipherId, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.soft_delete).toHaveBeenCalledWith(testCipherId); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.soft_delete.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete cipher"), + ); + }); + }); + + describe("softDeleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should soft delete multiple ciphers using SDK when asAdmin is false", async () => { + await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should soft delete multiple ciphers using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds, orgId); + }); + + it("should throw error when asAdmin is true but orgId is missing", async () => { + await expect( + cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, undefined), + ).rejects.toThrow("Organization ID is required for admin soft delete."); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect( + cipherSdkService.softDeleteManyWithServer(testCipherIds, userId), + ).rejects.toThrow("SDK not available"); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete multiple ciphers"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.soft_delete_many.mockRejectedValue(new Error("SDK error")); + + await expect( + cipherSdkService.softDeleteManyWithServer(testCipherIds, userId), + ).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete multiple ciphers"), + ); + }); + }); + + describe("restoreWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should restore cipher using SDK when asAdmin is false", async () => { + await cipherSdkService.restoreWithServer(testCipherId, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.restore).toHaveBeenCalledWith(testCipherId); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should restore cipher using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.restoreWithServer(testCipherId, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.restore).toHaveBeenCalledWith(testCipherId); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.restore.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore cipher"), + ); + }); + }); + + describe("restoreManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should restore multiple ciphers using SDK when orgId is not provided", async () => { + await cipherSdkService.restoreManyWithServer(testCipherIds, userId); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.restore_many).toHaveBeenCalledWith(testCipherIds); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should restore multiple ciphers using SDK admin API when orgId is provided", async () => { + const orgIdString = orgId as string; + await cipherSdkService.restoreManyWithServer(testCipherIds, userId, orgIdString); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.restore_many).toHaveBeenCalledWith(testCipherIds, orgIdString); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore multiple ciphers"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.restore_many.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore multiple ciphers"), + ); + }); + }); +}); diff --git a/libs/common/src/vault/services/cipher-sdk.service.ts b/libs/common/src/vault/services/cipher-sdk.service.ts new file mode 100644 index 00000000000..9757b3d2cc7 --- /dev/null +++ b/libs/common/src/vault/services/cipher-sdk.service.ts @@ -0,0 +1,263 @@ +import { firstValueFrom, switchMap, catchError } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SdkService, asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; + +import { CipherSdkService } from "../abstractions/cipher-sdk.service"; + +export class DefaultCipherSdkService implements CipherSdkService { + constructor( + private sdkService: SdkService, + private logService: LogService, + ) {} + + async createWithServer( + cipherView: CipherView, + userId: UserId, + orgAdmin?: boolean, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + const sdkCreateRequest = cipherView.toSdkCreateCipherRequest(); + let result: SdkCipherView; + if (orgAdmin) { + result = await ref.value.vault().ciphers().admin().create(sdkCreateRequest); + } else { + result = await ref.value.vault().ciphers().create(sdkCreateRequest); + } + return CipherView.fromSdkCipherView(result); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to create cipher: ${error}`); + throw error; + }), + ), + ); + } + + async updateWithServer( + cipher: CipherView, + userId: UserId, + originalCipherView?: CipherView, + orgAdmin?: boolean, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + const sdkUpdateRequest = cipher.toSdkUpdateCipherRequest(); + let result: SdkCipherView; + if (orgAdmin) { + result = await ref.value + .vault() + .ciphers() + .admin() + .edit( + sdkUpdateRequest, + originalCipherView?.toSdkCipherView() || new CipherView().toSdkCipherView(), + ); + } else { + result = await ref.value.vault().ciphers().edit(sdkUpdateRequest); + } + return CipherView.fromSdkCipherView(result); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to update cipher: ${error}`); + throw error; + }), + ), + ); + } + + async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + await ref.value.vault().ciphers().admin().delete(asUuid(id)); + } else { + await ref.value.vault().ciphers().delete(asUuid(id)); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to delete cipher: ${error}`); + throw error; + }), + ), + ); + } + + async deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + if (orgId == null) { + throw new Error("Organization ID is required for admin delete."); + } + await ref.value + .vault() + .ciphers() + .admin() + .delete_many( + ids.map((id) => asUuid(id)), + asUuid(orgId), + ); + } else { + await ref.value + .vault() + .ciphers() + .delete_many(ids.map((id) => asUuid(id))); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to delete multiple ciphers: ${error}`); + throw error; + }), + ), + ); + } + + async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + await ref.value.vault().ciphers().admin().soft_delete(asUuid(id)); + } else { + await ref.value.vault().ciphers().soft_delete(asUuid(id)); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to soft delete cipher: ${error}`); + throw error; + }), + ), + ); + } + + async softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + if (orgId == null) { + throw new Error("Organization ID is required for admin soft delete."); + } + await ref.value + .vault() + .ciphers() + .admin() + .soft_delete_many( + ids.map((id) => asUuid(id)), + asUuid(orgId), + ); + } else { + await ref.value + .vault() + .ciphers() + .soft_delete_many(ids.map((id) => asUuid(id))); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to soft delete multiple ciphers: ${error}`); + throw error; + }), + ), + ); + } + + async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + await ref.value.vault().ciphers().admin().restore(asUuid(id)); + } else { + await ref.value.vault().ciphers().restore(asUuid(id)); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to restore cipher: ${error}`); + throw error; + }), + ), + ); + } + + async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + + // No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable + // The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore + if (orgId) { + await ref.value + .vault() + .ciphers() + .admin() + .restore_many( + ids.map((id) => asUuid(id)), + asUuid(orgId), + ); + } else { + await ref.value + .vault() + .ciphers() + .restore_many(ids.map((id) => asUuid(id))); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to restore multiple ciphers: ${error}`); + throw error; + }), + ), + ); + } +} diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 153bb01403c..28b1f064d89 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -28,6 +28,7 @@ import { ContainerService } from "../../platform/services/container.service"; import { CipherId, UserId, OrganizationId, CollectionId } from "../../types/guid"; import { CipherKey, OrgKey, UserKey } from "../../types/key"; import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; +import { CipherSdkService } from "../abstractions/cipher-sdk.service"; import { EncryptionContext } from "../abstractions/cipher.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { SearchService } from "../abstractions/search.service"; @@ -54,9 +55,9 @@ function encryptText(clearText: string | Uint8Array) { const ENCRYPTED_BYTES = mock(); const cipherData: CipherData = { - id: "id", - organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId, - folderId: "folderId", + id: "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId, + folderId: "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23", edit: true, viewPassword: true, organizationUseTotp: true, @@ -109,12 +110,15 @@ describe("Cipher Service", () => { const stateProvider = new FakeStateProvider(accountService); const cipherEncryptionService = mock(); const messageSender = mock(); + const cipherSdkService = mock(); const userId = "TestUserId" as UserId; - const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId; + const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId; let cipherService: CipherService; let encryptionContext: EncryptionContext; + // BehaviorSubject for SDK feature flag - allows tests to change the value after service instantiation + let sdkCrudFeatureFlag$: BehaviorSubject; beforeEach(() => { encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES)); @@ -130,6 +134,10 @@ describe("Cipher Service", () => { (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + // Create BehaviorSubject for SDK feature flag - tests can update this to change behavior + sdkCrudFeatureFlag$ = new BehaviorSubject(false); + configService.getFeatureFlag$.mockReturnValue(sdkCrudFeatureFlag$.asObservable()); + cipherService = new CipherService( keyService, domainSettingsService, @@ -145,6 +153,7 @@ describe("Cipher Service", () => { logService, cipherEncryptionService, messageSender, + cipherSdkService, ); encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId }; @@ -207,11 +216,22 @@ describe("Cipher Service", () => { }); describe("createWithServer()", () => { + beforeEach(() => { + jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext); + jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => { + return new CipherView(cipher); + }); + }); + it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); const spy = jest .spyOn(apiService, "postCipherAdmin") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.createWithServer(encryptionContext, true); + const cipherView = new CipherView(encryptionContext.cipher); + await cipherService.createWithServer(cipherView, userId, true); const expectedObj = new CipherCreateRequest(encryptionContext); expect(spy).toHaveBeenCalled(); @@ -219,11 +239,15 @@ describe("Cipher Service", () => { }); it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); encryptionContext.cipher.organizationId = null!; const spy = jest .spyOn(apiService, "postCipher") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.createWithServer(encryptionContext, true); + const cipherView = new CipherView(encryptionContext.cipher); + await cipherService.createWithServer(cipherView, userId, true); const expectedObj = new CipherRequest(encryptionContext); expect(spy).toHaveBeenCalled(); @@ -231,11 +255,15 @@ describe("Cipher Service", () => { }); it("should call apiService.postCipherCreate if collectionsIds != null", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); encryptionContext.cipher.collectionIds = ["123"]; const spy = jest .spyOn(apiService, "postCipherCreate") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.createWithServer(encryptionContext); + const cipherView = new CipherView(encryptionContext.cipher); + await cipherService.createWithServer(cipherView, userId); const expectedObj = new CipherCreateRequest(encryptionContext); expect(spy).toHaveBeenCalled(); @@ -243,35 +271,84 @@ describe("Cipher Service", () => { }); it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); const spy = jest .spyOn(apiService, "postCipher") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.createWithServer(encryptionContext); + const cipherView = new CipherView(encryptionContext.cipher); + await cipherService.createWithServer(cipherView, userId); const expectedObj = new CipherRequest(encryptionContext); expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith(expectedObj); }); + + it("should delegate to cipherSdkService when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const cipherView = new CipherView(encryptionContext.cipher); + const expectedResult = new CipherView(encryptionContext.cipher); + + const cipherSdkServiceSpy = jest + .spyOn(cipherSdkService, "createWithServer") + .mockResolvedValue(expectedResult); + + const clearCacheSpy = jest.spyOn(cipherService, "clearCache"); + const apiSpy = jest.spyOn(apiService, "postCipher"); + + const result = await cipherService.createWithServer(cipherView, userId); + + expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined); + expect(apiSpy).not.toHaveBeenCalled(); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + expect(result).toBeInstanceOf(CipherView); + }); }); describe("updateWithServer()", () => { + beforeEach(() => { + jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext); + jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => { + return new CipherView(cipher); + }); + jest.spyOn(cipherService, "upsert").mockResolvedValue({ + [cipherData.id as CipherId]: cipherData, + }); + }); + it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const testCipher = new Cipher(cipherData); + testCipher.organizationId = orgId; + const testContext = { cipher: testCipher, encryptedFor: userId }; + jest.spyOn(cipherService, "encrypt").mockResolvedValue(testContext); + const spy = jest .spyOn(apiService, "putCipherAdmin") - .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.updateWithServer(encryptionContext, true); - const expectedObj = new CipherRequest(encryptionContext); + .mockImplementation(() => Promise.resolve(testCipher.toCipherData())); + const cipherView = new CipherView(testCipher); + await cipherService.updateWithServer(cipherView, userId, undefined, true); + const expectedObj = new CipherRequest(testContext); expect(spy).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj); + expect(spy).toHaveBeenCalledWith(testCipher.id, expectedObj); }); it("should call apiService.putCipher if cipher.edit is true", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); encryptionContext.cipher.edit = true; const spy = jest .spyOn(apiService, "putCipher") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.updateWithServer(encryptionContext); + const cipherView = new CipherView(encryptionContext.cipher); + await cipherService.updateWithServer(cipherView, userId); const expectedObj = new CipherRequest(encryptionContext); expect(spy).toHaveBeenCalled(); @@ -279,16 +356,75 @@ describe("Cipher Service", () => { }); it("should call apiService.putPartialCipher when orgAdmin, and edit are false", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); encryptionContext.cipher.edit = false; const spy = jest .spyOn(apiService, "putPartialCipher") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.updateWithServer(encryptionContext); + const cipherView = new CipherView(encryptionContext.cipher); + await cipherService.updateWithServer(cipherView, userId); const expectedObj = new CipherPartialRequest(encryptionContext.cipher); expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj); }); + + it("should delegate to cipherSdkService when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const testCipher = new Cipher(cipherData); + const cipherView = new CipherView(testCipher); + const expectedResult = new CipherView(testCipher); + + const cipherSdkServiceSpy = jest + .spyOn(cipherSdkService, "updateWithServer") + .mockResolvedValue(expectedResult); + + const clearCacheSpy = jest.spyOn(cipherService, "clearCache"); + const apiSpy = jest.spyOn(apiService, "putCipher"); + + const result = await cipherService.updateWithServer(cipherView, userId); + + expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined, undefined); + expect(apiSpy).not.toHaveBeenCalled(); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + expect(result).toBeInstanceOf(CipherView); + }); + + it("should delegate to cipherSdkService with orgAdmin when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const testCipher = new Cipher(cipherData); + const cipherView = new CipherView(testCipher); + const originalCipherView = new CipherView(testCipher); + const expectedResult = new CipherView(testCipher); + + const cipherSdkServiceSpy = jest + .spyOn(cipherSdkService, "updateWithServer") + .mockResolvedValue(expectedResult); + + const clearCacheSpy = jest.spyOn(cipherService, "clearCache"); + const apiSpy = jest.spyOn(apiService, "putCipherAdmin"); + + const result = await cipherService.updateWithServer( + cipherView, + userId, + originalCipherView, + true, + ); + + expect(cipherSdkServiceSpy).toHaveBeenCalledWith( + cipherView, + userId, + originalCipherView, + true, + ); + expect(apiSpy).not.toHaveBeenCalled(); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + expect(result).toBeInstanceOf(CipherView); + }); }); describe("encrypt", () => { @@ -873,6 +1009,238 @@ describe("Cipher Service", () => { }); }); + describe("deleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should call apiService.deleteCipher when feature flag is disabled", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "deleteCipher").mockResolvedValue(undefined); + + await cipherService.deleteWithServer(testCipherId, userId); + + expect(apiSpy).toHaveBeenCalledWith(testCipherId); + }); + + it("should call apiService.deleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "deleteCipherAdmin").mockResolvedValue(undefined); + + await cipherService.deleteWithServer(testCipherId, userId, true); + + expect(apiSpy).toHaveBeenCalledWith(testCipherId); + }); + + it("should use SDK to delete cipher when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.deleteWithServer(testCipherId, userId, false); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + + it("should use SDK admin delete when feature flag is enabled and asAdmin is true", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.deleteWithServer(testCipherId, userId, true); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + }); + + describe("deleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should call apiService.deleteManyCiphers when feature flag is disabled", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "deleteManyCiphers").mockResolvedValue(undefined); + + await cipherService.deleteManyWithServer(testCipherIds, userId); + + expect(apiSpy).toHaveBeenCalled(); + }); + + it("should call apiService.deleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "deleteManyCiphersAdmin").mockResolvedValue(undefined); + + await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(apiSpy).toHaveBeenCalled(); + }); + + it("should use SDK to delete multiple ciphers when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteManyWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.deleteManyWithServer(testCipherIds, userId, false); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + + it("should use SDK admin delete many when feature flag is enabled and asAdmin is true", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteManyWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + }); + + describe("softDeleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should call apiService.putDeleteCipher when feature flag is disabled", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "putDeleteCipher").mockResolvedValue(undefined); + + await cipherService.softDeleteWithServer(testCipherId, userId); + + expect(apiSpy).toHaveBeenCalledWith(testCipherId); + }); + + it("should call apiService.putDeleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "putDeleteCipherAdmin").mockResolvedValue(undefined); + + await cipherService.softDeleteWithServer(testCipherId, userId, true); + + expect(apiSpy).toHaveBeenCalledWith(testCipherId); + }); + + it("should use SDK to soft delete cipher when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.softDeleteWithServer(testCipherId, userId, false); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + + it("should use SDK admin soft delete when feature flag is enabled and asAdmin is true", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.softDeleteWithServer(testCipherId, userId, true); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + }); + + describe("softDeleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should call apiService.putDeleteManyCiphers when feature flag is disabled", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "putDeleteManyCiphers").mockResolvedValue(undefined); + + await cipherService.softDeleteManyWithServer(testCipherIds, userId); + + expect(apiSpy).toHaveBeenCalled(); + }); + + it("should call apiService.putDeleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest + .spyOn(apiService, "putDeleteManyCiphersAdmin") + .mockResolvedValue(undefined); + + await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(apiSpy).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId }); + }); + + it("should use SDK to soft delete multiple ciphers when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteManyWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.softDeleteManyWithServer(testCipherIds, userId, false); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + + it("should use SDK admin soft delete many when feature flag is enabled and asAdmin is true", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteManyWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + }); + describe("replace (no upsert)", () => { // In order to set up initial state we need to manually update the encrypted state // which will result in an emission. All tests will have this baseline emission. diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 2e0adc892e3..81060870e8b 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -42,6 +42,7 @@ import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid import { OrgKey, UserKey } from "../../types/key"; import { filterOutNullish, perUserCache$ } from "../../vault/utils/observable-utilities"; import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; +import { CipherSdkService } from "../abstractions/cipher-sdk.service"; import { CipherService as CipherServiceAbstraction, EncryptionContext, @@ -105,6 +106,13 @@ export class CipherService implements CipherServiceAbstraction { */ private clearCipherViewsForUser$: Subject = new Subject(); + /** + * Observable exposing the feature flag status for using the SDK for cipher CRUD operations. + */ + private readonly sdkCipherCrudEnabled$: Observable = this.configService.getFeatureFlag$( + FeatureFlag.PM27632_SdkCipherCrudOperations, + ); + constructor( private keyService: KeyService, private domainSettingsService: DomainSettingsService, @@ -120,6 +128,7 @@ export class CipherService implements CipherServiceAbstraction { private logService: LogService, private cipherEncryptionService: CipherEncryptionService, private messageSender: MessageSender, + private cipherSdkService: CipherSdkService, ) {} localData$(userId: UserId): Observable> { @@ -903,6 +912,38 @@ export class CipherService implements CipherServiceAbstraction { } async createWithServer( + cipherView: CipherView, + userId: UserId, + orgAdmin?: boolean, + ): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + + if (useSdk) { + return ( + (await this.createWithServerUsingSdk(cipherView, userId, orgAdmin)) || new CipherView() + ); + } + + const encrypted = await this.encrypt(cipherView, userId); + const result = await this.createWithServer_legacy(encrypted, orgAdmin); + return await this.decrypt(result, userId); + } + + private async createWithServerUsingSdk( + cipherView: CipherView, + userId: UserId, + orgAdmin?: boolean, + ): Promise { + const resultCipherView = await this.cipherSdkService.createWithServer( + cipherView, + userId, + orgAdmin, + ); + await this.clearCache(userId); + return resultCipherView; + } + + private async createWithServer_legacy( { cipher, encryptedFor }: EncryptionContext, orgAdmin?: boolean, ): Promise { @@ -929,6 +970,40 @@ export class CipherService implements CipherServiceAbstraction { } async updateWithServer( + cipherView: CipherView, + userId: UserId, + originalCipherView?: CipherView, + orgAdmin?: boolean, + ): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + + if (useSdk) { + return await this.updateWithServerUsingSdk(cipherView, userId, originalCipherView, orgAdmin); + } + + const encrypted = await this.encrypt(cipherView, userId); + const updatedCipher = await this.updateWithServer_legacy(encrypted, orgAdmin); + const updatedCipherView = await this.decrypt(updatedCipher, userId); + return updatedCipherView; + } + + async updateWithServerUsingSdk( + cipher: CipherView, + userId: UserId, + originalCipherView?: CipherView, + orgAdmin?: boolean, + ): Promise { + const resultCipherView = await this.cipherSdkService.updateWithServer( + cipher, + userId, + originalCipherView, + orgAdmin, + ); + await this.clearCache(userId); + return resultCipherView; + } + + async updateWithServer_legacy( { cipher, encryptedFor }: EncryptionContext, orgAdmin?: boolean, ): Promise { @@ -1119,8 +1194,7 @@ export class CipherService implements CipherServiceAbstraction { //in order to keep item and it's attachments with the same encryption level if (cipher.key != null && !cipherKeyEncryptionEnabled) { const model = await this.decrypt(cipher, userId); - const reEncrypted = await this.encrypt(model, userId); - await this.updateWithServer(reEncrypted); + await this.updateWithServer(model, userId); } const encFileName = await this.encryptService.encryptString(filename, cipherEncKey); @@ -1318,7 +1392,14 @@ export class CipherService implements CipherServiceAbstraction { await this.encryptedCiphersState(userId).update(() => ciphers); } - async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.deleteWithServer(id, userId, asAdmin); + await this.clearCache(userId); + return; + } + if (asAdmin) { await this.apiService.deleteCipherAdmin(id); } else { @@ -1328,7 +1409,19 @@ export class CipherService implements CipherServiceAbstraction { await this.delete(id, userId); } - async deleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise { + async deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.deleteManyWithServer(ids, userId, asAdmin, orgId); + await this.clearCache(userId); + return; + } + const request = new CipherBulkDeleteRequest(ids); if (asAdmin) { await this.apiService.deleteManyCiphersAdmin(request); @@ -1468,7 +1561,7 @@ export class CipherService implements CipherServiceAbstraction { }; } - async softDelete(id: string | string[], userId: UserId): Promise { + async softDelete(id: string | string[], userId: UserId): Promise { let ciphers = await firstValueFrom(this.ciphers$(userId)); if (ciphers == null) { return; @@ -1496,7 +1589,14 @@ export class CipherService implements CipherServiceAbstraction { }); } - async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.softDeleteWithServer(id, userId, asAdmin); + await this.clearCache(userId); + return; + } + if (asAdmin) { await this.apiService.putDeleteCipherAdmin(id); } else { @@ -1506,8 +1606,20 @@ export class CipherService implements CipherServiceAbstraction { await this.softDelete(id, userId); } - async softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise { - const request = new CipherBulkDeleteRequest(ids); + async softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.softDeleteManyWithServer(ids, userId, asAdmin, orgId); + await this.clearCache(userId); + return; + } + + const request = new CipherBulkDeleteRequest(ids, orgId); if (asAdmin) { await this.apiService.putDeleteManyCiphersAdmin(request); } else { @@ -1550,7 +1662,14 @@ export class CipherService implements CipherServiceAbstraction { }); } - async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise { + async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.restoreWithServer(id, userId, asAdmin); + await this.clearCache(userId); + return; + } + let response; if (asAdmin) { response = await this.apiService.putRestoreCipherAdmin(id); @@ -1566,6 +1685,13 @@ export class CipherService implements CipherServiceAbstraction { * The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore */ async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.restoreManyWithServer(ids, userId, orgId); + await this.clearCache(userId); + return; + } + let response; if (orgId) { diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 84140a8953a..b8f8851864b 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -3,7 +3,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; import { Subject, filter, of, switchMap, tap } from "rxjs"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Translation } from "../dialog"; @@ -27,7 +27,7 @@ export interface AnonLayoutWrapperData { /** * The icon to display on the page. Pass null to hide the icon. */ - pageIcon: Icon | null; + pageIcon: BitSvg | null; /** * Optional flag to either show the optional environment selector (false) or just a readonly hostname (true). */ @@ -57,7 +57,7 @@ export class AnonLayoutWrapperComponent implements OnInit { protected pageTitle?: string | null; protected pageSubtitle?: string | null; - protected pageIcon: Icon | null = null; + protected pageIcon: BitSvg | null = null; protected showReadonlyHostname?: boolean | null; protected maxWidth?: LandingContentMaxWidthType | null; protected hideCardWrapper?: boolean | null; diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index eded556cd53..953a5e769cf 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -11,15 +11,15 @@ import { import { RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; -import { BitwardenLogo, Icon } from "@bitwarden/assets/svg"; +import { BitwardenLogo, BitSvg } from "@bitwarden/assets/svg"; import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { IconModule } from "../icon"; import { LandingContentMaxWidthType } from "../landing-layout"; import { LandingLayoutModule } from "../landing-layout/landing-layout.module"; import { SharedModule } from "../shared"; +import { SvgModule } from "../svg"; import { TypographyModule } from "../typography"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -28,7 +28,7 @@ import { TypographyModule } from "../typography"; selector: "auth-anon-layout", templateUrl: "./anon-layout.component.html", imports: [ - IconModule, + SvgModule, CommonModule, TypographyModule, SharedModule, @@ -45,7 +45,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { readonly title = input(); readonly subtitle = input(); - readonly icon = model.required(); + readonly icon = model.required(); readonly showReadonlyHostname = input(false); readonly hideLogo = input(false); readonly hideFooter = input(false); diff --git a/libs/components/src/anon-layout/anon-layout.stories.ts b/libs/components/src/anon-layout/anon-layout.stories.ts index 01cdc04ad73..ed6df181c85 100644 --- a/libs/components/src/anon-layout/anon-layout.stories.ts +++ b/libs/components/src/anon-layout/anon-layout.stories.ts @@ -2,7 +2,7 @@ import { ActivatedRoute, RouterModule } from "@angular/router"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { BehaviorSubject, of } from "rxjs"; -import { Icon, LockIcon } from "@bitwarden/assets/svg"; +import { BitSvg, LockIcon } from "@bitwarden/assets/svg"; import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -23,7 +23,7 @@ type StoryArgs = AnonLayoutComponent & { contentLength: "normal" | "long" | "thin"; showSecondary: boolean; useDefaultIcon: boolean; - icon: Icon; + icon: BitSvg; includeHeaderActions: boolean; }; diff --git a/libs/components/src/callout/callout.stories.ts b/libs/components/src/callout/callout.stories.ts index c2185203034..ff1a8c16d5f 100644 --- a/libs/components/src/callout/callout.stories.ts +++ b/libs/components/src/callout/callout.stories.ts @@ -1,7 +1,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LinkModule, IconModule } from "@bitwarden/components"; +import { LinkModule, SvgModule } from "@bitwarden/components"; import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet"; import { I18nMockService } from "../utils/i18n-mock.service"; @@ -13,7 +13,7 @@ export default { component: CalloutComponent, decorators: [ moduleMetadata({ - imports: [LinkModule, IconModule], + imports: [LinkModule, SvgModule], providers: [ { provide: I18nService, diff --git a/libs/components/src/dialog/dialog.service.ts b/libs/components/src/dialog/dialog.service.ts index 8393db57b2f..ed17cb27327 100644 --- a/libs/components/src/dialog/dialog.service.ts +++ b/libs/components/src/dialog/dialog.service.ts @@ -16,9 +16,9 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DrawerService } from "../drawer/drawer.service"; import { isAtOrLargerThanBreakpoint } from "../utils/responsive-utils"; +import { DrawerService } from "./drawer.service"; import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component"; import { SimpleDialogOptions } from "./simple-dialog/types"; diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts index f9073da2217..2ce19a9f9e0 100644 --- a/libs/components/src/dialog/dialog/dialog.component.ts +++ b/libs/components/src/dialog/dialog/dialog.component.ts @@ -145,6 +145,26 @@ export class DialogComponent implements AfterViewInit { }); ngAfterViewInit() { + this.focusOnHeader(); + } + + handleEsc(event: Event) { + if (!this.dialogRef?.disableClose) { + this.dialogRef?.close(); + event.stopPropagation(); + } + } + + onAnimationEnd() { + this.animationCompleted.set(true); + } + + /** + * Moves focus to the dialog header element. + * This is done automatically when the dialog is opened but can be called manually + * when the contents of the dialog change and focus should be reset. + */ + focusOnHeader(): void { /** * Wait a tick for any focus management to occur on the trigger element before moving focus to * the dialog header. We choose the dialog header because it is always present, unlike possible @@ -159,15 +179,4 @@ export class DialogComponent implements AfterViewInit { this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout)); } - - handleEsc(event: Event) { - if (!this.dialogRef?.disableClose) { - this.dialogRef?.close(); - event.stopPropagation(); - } - } - - onAnimationEnd() { - this.animationCompleted.set(true); - } } diff --git a/libs/components/src/drawer/drawer.service.ts b/libs/components/src/dialog/drawer.service.ts similarity index 100% rename from libs/components/src/drawer/drawer.service.ts rename to libs/components/src/dialog/drawer.service.ts diff --git a/libs/components/src/dialog/index.ts b/libs/components/src/dialog/index.ts index fb4c2721b81..ce41f7957f6 100644 --- a/libs/components/src/dialog/index.ts +++ b/libs/components/src/dialog/index.ts @@ -2,3 +2,4 @@ export * from "./dialog.module"; export * from "./simple-dialog/types"; export * from "./dialog.service"; export { DIALOG_DATA } from "@angular/cdk/dialog"; +export { DialogComponent } from "./dialog/dialog.component"; diff --git a/libs/components/src/drawer/drawer-body.component.ts b/libs/components/src/drawer/drawer-body.component.ts deleted file mode 100644 index c6499067642..00000000000 --- a/libs/components/src/drawer/drawer-body.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CdkScrollable } from "@angular/cdk/scrolling"; -import { ChangeDetectionStrategy, Component } from "@angular/core"; - -import { hasScrolledFrom } from "../utils/has-scrolled-from"; - -/** - * Body container for `bit-drawer` - */ -@Component({ - selector: "bit-drawer-body", - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [], - host: { - class: - "tw-p-4 tw-pt-0 tw-flex-1 tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200", - "[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top", - }, - hostDirectives: [ - { - directive: CdkScrollable, - }, - ], - template: ` `, -}) -export class DrawerBodyComponent { - protected hasScrolledFrom = hasScrolledFrom(); -} diff --git a/libs/components/src/drawer/drawer-close.directive.ts b/libs/components/src/drawer/drawer-close.directive.ts deleted file mode 100644 index f105e21ea62..00000000000 --- a/libs/components/src/drawer/drawer-close.directive.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Directive, inject } from "@angular/core"; - -import { DrawerComponent } from "./drawer.component"; - -/** - * Closes the ancestor drawer - * - * @example - * - * ```html - * - * - * - * ``` - **/ -@Directive({ - selector: "button[bitDrawerClose]", - host: { - "(click)": "onClick()", - }, -}) -export class DrawerCloseDirective { - private drawer = inject(DrawerComponent, { optional: true }); - - protected onClick() { - this.drawer?.open.set(false); - } -} diff --git a/libs/components/src/drawer/drawer-header.component.html b/libs/components/src/drawer/drawer-header.component.html deleted file mode 100644 index 2723744eda3..00000000000 --- a/libs/components/src/drawer/drawer-header.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
-
- -

- {{ title() }} -

-
- -
diff --git a/libs/components/src/drawer/drawer-header.component.ts b/libs/components/src/drawer/drawer-header.component.ts deleted file mode 100644 index 006c48e091d..00000000000 --- a/libs/components/src/drawer/drawer-header.component.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { ChangeDetectionStrategy, Component, HostBinding, input } from "@angular/core"; - -import { I18nPipe } from "@bitwarden/ui-common"; - -import { IconButtonModule } from "../icon-button"; -import { TypographyModule } from "../typography"; - -import { DrawerCloseDirective } from "./drawer-close.directive"; - -/** - * Header container for `bit-drawer` - **/ -@Component({ - selector: "bit-drawer-header", - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, DrawerCloseDirective, TypographyModule, IconButtonModule, I18nPipe], - templateUrl: "drawer-header.component.html", - host: { - class: "tw-block tw-ps-4 tw-pe-2 tw-py-2", - }, -}) -export class DrawerHeaderComponent { - /** - * The title to display - */ - readonly title = input.required(); - - /** We don't want to set the HTML title attribute with `this.title` */ - @HostBinding("attr.title") - protected get getTitle(): null { - return null; - } -} diff --git a/libs/components/src/drawer/drawer-host.directive.ts b/libs/components/src/drawer/drawer-host.directive.ts deleted file mode 100644 index 7804d111ed6..00000000000 --- a/libs/components/src/drawer/drawer-host.directive.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Portal } from "@angular/cdk/portal"; -import { Directive, signal } from "@angular/core"; - -/** - * Host that renders a drawer - * - * @internal - */ -@Directive({ - selector: "[bitDrawerHost]", -}) -export class DrawerHostDirective { - private readonly _portal = signal | undefined>(undefined); - - /** The portal to display */ - portal = this._portal.asReadonly(); - - open(portal: Portal) { - this._portal.set(portal); - } - - close(portal: Portal) { - if (portal === this.portal()) { - this._portal.set(undefined); - } - } -} diff --git a/libs/components/src/drawer/drawer.component.html b/libs/components/src/drawer/drawer.component.html deleted file mode 100644 index 79cbf319e7d..00000000000 --- a/libs/components/src/drawer/drawer.component.html +++ /dev/null @@ -1,8 +0,0 @@ - -
- -
-
diff --git a/libs/components/src/drawer/drawer.component.ts b/libs/components/src/drawer/drawer.component.ts deleted file mode 100644 index 042d1eace79..00000000000 --- a/libs/components/src/drawer/drawer.component.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { CdkPortal, PortalModule } from "@angular/cdk/portal"; -import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - effect, - inject, - input, - model, - viewChild, -} from "@angular/core"; - -import { DrawerService } from "./drawer.service"; - -/** - * A drawer is a panel of supplementary content that is adjacent to the page's main content. - * - * Drawers render in `bit-layout`. Drawers must be a descendant of `bit-layout`, but they do not need to be a direct descendant. - */ -@Component({ - selector: "bit-drawer", - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, PortalModule], - templateUrl: "drawer.component.html", -}) -export class DrawerComponent { - private drawerHost = inject(DrawerService); - private readonly portal = viewChild.required(CdkPortal); - - /** - * Whether or not the drawer is open. - * - * Note: Does not support implicit boolean transform due to Angular limitation. Must be bound explicitly `[open]="true"` instead of just `open`. - * https://github.com/angular/angular/issues/55166#issuecomment-2032150999 - **/ - readonly open = model(false); - - /** - * The ARIA role of the drawer. - * - * - [complementary](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/complementary_role) - * - For drawers that contain content that is complementary to the page's main content. (default) - * - [navigation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/navigation_role) - * - For drawers that primary contain links to other content. - */ - readonly role = input<"complementary" | "navigation">("complementary"); - - constructor() { - effect( - () => { - this.open() ? this.drawerHost.open(this.portal()) : this.drawerHost.close(this.portal()); - }, - { - allowSignalWrites: true, - }, - ); - - // Set `open` to `false` when another drawer is opened. - effect( - () => { - if (this.drawerHost.portal() !== this.portal()) { - this.open.set(false); - } - }, - { - allowSignalWrites: true, - }, - ); - } - - /** Toggle the drawer between open & closed */ - toggle() { - this.open.update((prev) => !prev); - } -} diff --git a/libs/components/src/drawer/drawer.mdx b/libs/components/src/drawer/drawer.mdx deleted file mode 100644 index 1050ab476f7..00000000000 --- a/libs/components/src/drawer/drawer.mdx +++ /dev/null @@ -1,122 +0,0 @@ -import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs/blocks"; - -import * as stories from "./drawer.stories"; - -import { DrawerOpen as KitchenSink } from "../stories/kitchen-sink/kitchen-sink.stories"; - - - -```ts -import { DrawerComponent } from "@bitwarden/components"; -``` - -# Drawer - -**Note: `bit-drawer` is deprecated. Use `bit-dialog` and `DialogService.openDrawer(...)` instead.** - -A drawer is a panel of supplementary content that is adjacent to the page's main content. - - - - - -## Usage - -A `bit-drawer` in a template will not render inline, but rather will render adjacent to the main -page content. - -```html - - - -

Lorem ipsum dolor...

-
-
-``` - -`bit-drawer` must be a descendant of `bit-layout`, but it does not need to be a direct descendant. - -## Header and body - -Header and body content can be provided with the `bit-drawer-header` and `bit-drawer-body` -components, respectively. - -A title can be passed to the header by input: -`` - -Custom content can be rendered before the title with the header's `start` slot: - -```html - - - -``` - -## Opening and closing - -`bit-drawer` opens when its `open` input is `true`: - -```html -... -``` - -Note: Model inputs do not support implicit boolean transformation (see Angular reasoning -[here](https://github.com/angular/angular/issues/55166#issuecomment-2032150999)). `open` must be -bound explicitly `` instead of just ``. - -Buttons can be made to open/toggle drawers by referencing a template variable, or by manipulating -state that is bound to `open`: - -```html - ... -``` - -For convenience, close buttons can be created _inside_ the drawer with the `bitDrawerClose` -directive: - -```html - - - -``` - -## Multiple Drawers - -Only one drawer can be open at a time, and they do not stack. If a drawer is already open, opening -another will close and replace the one already open. - - - -## Headless - -Omitting `bit-drawer-header` and `bit-drawer-body` allows for fully customizable content. - - - -## Accessibility - -- The drawer should contain an h2 element. If you are using `bit-drawer-header`, this is created for - you via the `title` input: - -```html - -

Hello world!

-
- - - - - - -``` - -- The ARIA role of the drawer can be set with the `role` attribute: - - [complementary](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/complementary_role) - (default) - - For drawers that contain content that is complementary to the page's main content. - - [navigation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/navigation_role) - - For drawers that primary contain links to other content. - -## Kitchen Sink - - diff --git a/libs/components/src/drawer/drawer.module.ts b/libs/components/src/drawer/drawer.module.ts deleted file mode 100644 index 9f51ba06b4e..00000000000 --- a/libs/components/src/drawer/drawer.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NgModule } from "@angular/core"; - -import { DrawerBodyComponent } from "./drawer-body.component"; -import { DrawerCloseDirective } from "./drawer-close.directive"; -import { DrawerHeaderComponent } from "./drawer-header.component"; -import { DrawerComponent } from "./drawer.component"; - -@NgModule({ - imports: [DrawerComponent, DrawerHeaderComponent, DrawerBodyComponent, DrawerCloseDirective], - exports: [DrawerComponent, DrawerHeaderComponent, DrawerBodyComponent, DrawerCloseDirective], -}) -export class DrawerModule {} diff --git a/libs/components/src/drawer/drawer.stories.ts b/libs/components/src/drawer/drawer.stories.ts deleted file mode 100644 index 9904b77ee9f..00000000000 --- a/libs/components/src/drawer/drawer.stories.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; - -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { GlobalStateProvider } from "@bitwarden/state"; - -import { ButtonModule } from "../button"; -import { CalloutModule } from "../callout"; -import { LayoutComponent } from "../layout"; -import { mockLayoutI18n } from "../layout/mocks"; -import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; -import { TypographyModule } from "../typography"; -import { I18nMockService, StorybookGlobalStateProvider } from "../utils"; - -import { DrawerBodyComponent } from "./drawer-body.component"; -import { DrawerHeaderComponent } from "./drawer-header.component"; -import { DrawerComponent } from "./drawer.component"; -import { DrawerModule } from "./drawer.module"; - -export default { - title: "Component Library/Drawer", - component: DrawerComponent, - subcomponents: { - DrawerHeaderComponent, - DrawerBodyComponent, - }, - decorators: [ - positionFixedWrapperDecorator(), - moduleMetadata({ - imports: [ - RouterTestingModule, - LayoutComponent, - DrawerModule, - ButtonModule, - CalloutModule, - TypographyModule, - ], - providers: [ - { - provide: I18nService, - useFactory: () => { - return new I18nMockService({ - ...mockLayoutI18n, - close: "Close", - loading: "Loading", - }); - }, - }, - ], - }), - applicationConfig({ - providers: [ - { - provide: GlobalStateProvider, - useClass: StorybookGlobalStateProvider, - }, - ], - }), - ], -} as Meta; - -type Story = StoryObj; - -export const Default: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - -

The drawer is {{ open ? "open" : "closed" }}.

- - - - - - - - -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. -

- -
- - `, - }), - args: { - open: true, - }, -}; - -export const Headless: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - -

The drawer is {{ open ? "open" : "closed" }}.

- - -

- Hello world! -
- - `, - }), - args: { - open: true, - }, -}; - -export const MultipleDrawers: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - - - - - - Foo - - - - Bar - - - `, - }), -}; diff --git a/libs/components/src/drawer/index.ts b/libs/components/src/drawer/index.ts deleted file mode 100644 index abf5b8d34f1..00000000000 --- a/libs/components/src/drawer/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./drawer.module"; -export * from "./drawer.component"; -export * from "./drawer-body.component"; -export * from "./drawer-close.directive"; -export * from "./drawer-header.component"; diff --git a/libs/components/src/header/header.stories.ts b/libs/components/src/header/header.stories.ts index 620f39a5dc3..23c2bb2edb5 100644 --- a/libs/components/src/header/header.stories.ts +++ b/libs/components/src/header/header.stories.ts @@ -14,7 +14,7 @@ import { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, @@ -40,7 +40,7 @@ export default { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, diff --git a/libs/components/src/icon/icon.component.ts b/libs/components/src/icon/icon.component.ts index f57a3627383..c2dc468dc71 100644 --- a/libs/components/src/icon/icon.component.ts +++ b/libs/components/src/icon/icon.component.ts @@ -1,35 +1,30 @@ -import { Component, effect, input } from "@angular/core"; -import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; -import { Icon, isIcon } from "@bitwarden/assets/svg"; +import { BitwardenIcon } from "../shared/icon"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-icon", + standalone: true, host: { - "[attr.aria-hidden]": "!ariaLabel()", + "[class]": "classList()", + "[attr.aria-hidden]": "ariaLabel() ? null : true", "[attr.aria-label]": "ariaLabel()", - "[innerHtml]": "innerHtml", - class: "tw-max-h-full tw-flex tw-justify-center", }, template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BitIconComponent { - innerHtml: SafeHtml | null = null; - - readonly icon = input(); +export class IconComponent { + /** + * The Bitwarden icon name (e.g., "bwi-lock", "bwi-user") + */ + readonly name = input.required(); + /** + * Accessible label for the icon + */ readonly ariaLabel = input(); - constructor(private domSanitizer: DomSanitizer) { - effect(() => { - const icon = this.icon(); - if (!isIcon(icon)) { - return; - } - const svg = icon.svg; - this.innerHtml = this.domSanitizer.bypassSecurityTrustHtml(svg); - }); - } + protected readonly classList = computed(() => { + return ["bwi", this.name()].join(" "); + }); } diff --git a/libs/components/src/icon/icon.mdx b/libs/components/src/icon/icon.mdx index 4f6f13c895e..0914d681e59 100644 --- a/libs/components/src/icon/icon.mdx +++ b/libs/components/src/icon/icon.mdx @@ -8,113 +8,40 @@ import * as stories from "./icon.stories"; import { IconModule } from "@bitwarden/components"; ``` -# Icon Use Instructions +# Icon -- Icons will generally be attached to the associated Jira task. - - Designers should minify any SVGs before attaching them to Jira using a tool like - [SVGOMG](https://jakearchibald.github.io/svgomg/). - - **Note:** Ensure the "Remove viewbox" option is toggled off if responsive resizing of the icon - is desired. +The `bit-icon` component renders Bitwarden Web Icons (bwi) using icon font classes. -## Developer Instructions +## Basic Usage -1. **Download the SVG** and import it as an `.svg` initially into the IDE of your choice. - - The SVG should be formatted using either a built-in formatter or an external tool like - [SVG Formatter Beautifier](https://codebeautify.org/svg-formatter-beautifier) to make applying - classes easier. +```html + +``` -2. **Rename the file** as a `.icon.ts` TypeScript file and place it in the `libs/assets/svg` - lib. +## Icon Names -3. **Import** `svgIcon` from `./icon-service`. +All available icon names are defined in the `BitwardenIcon` type. Icons use the `bwi-*` naming +convention (e.g., `bwi-lock`, `bwi-user`, `bwi-key`). -4. **Define and export** a `const` to represent your `svgIcon`. +## Accessibility - ```typescript - export const ExampleIcon = svgIcon``; - ``` +By default, icons are decorative and marked with `aria-hidden="true"`. To make an icon accessible, +provide an `ariaLabel`: -5. **Replace any hardcoded strokes or fills** with the appropriate Tailwind class. - - **Note:** Stroke is used when styling the outline of an SVG path, while fill is used when - styling the inside of an SVG path. +```html + +``` - - A non-comprehensive list of common colors and their associated classes is below: +## Styling - | Hardcoded Value | Tailwind Stroke Class | Tailwind Fill Class | Tailwind Variable | - | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------- | ----------------------------------- | - | `#020F66` | `tw-stroke-illustration-outline` | `tw-fill-illustration-outline` | `--color-illustration-outline` | - | `#DBE5F6` | `tw-stroke-illustration-bg-primary` | `tw-fill-illustration-bg-primary` | `--color-illustration-bg-primary` | - | `#AAC3EF` | `tw-stroke-illustration-bg-secondary` | `tw-fill-illustration-bg-secondary` | `--color-illustration-bg-secondary` | - | `#FFFFFF` | `tw-stroke-illustration-bg-tertiary` | `tw-fill-illustration-bg-tertiary` | `--color-illustration-bg-tertiary` | - | `#FFBF00` | `tw-stroke-illustration-tertiary` | `tw-fill-illustration-tertiary` | `--color-illustration-tertiary` | - | `#175DDC` | `tw-stroke-illustration-logo` | `tw-fill-illustration-logo` | `--color-illustration-logo` | +The component renders as an inline element. Apply standard CSS classes or styles to customize +appearance: - - If the hex that you have on an SVG path is not listed above, there are a few ways to figure out - the appropriate Tailwind class: - - **Option 1: Figma** - - Open the SVG in Figma. - - Click on an individual path on the SVG until you see the path's properties in the - right-hand panel. - - Scroll down to the Colors section. - - Example: `Color/Illustration/Outline` - - This also includes Hex or RGB values that can be used to find the appropriate Tailwind - variable as well if you follow the manual search option below. - - Create the appropriate stroke or fill class from the color used. - - Example: `Color/Illustration/Outline` corresponds to `--color-illustration-outline` which - corresponds to `tw-stroke-illustration-outline` or `tw-fill-illustration-outline`. - - **Option 2: Manual Search** - - Take the path's stroke or fill hex value and convert it to RGB using a tool like - [Hex to RGB](https://www.rgbtohex.net/hex-to-rgb/). - - Search for the RGB value without commas in our `tw-theme.css` to find the Tailwind variable - that corresponds to the color. - - Create the appropriate stroke or fill class using the Tailwind variable. - - Example: `--color-illustration-outline` corresponds to `tw-stroke-illustration-outline` - or `tw-fill-illustration-outline`. +```html + +``` -6. **Remove any hardcoded width or height attributes** if your SVG has a configured - [viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) attribute in order - to allow the SVG to scale to fit its container. - - **Note:** Scaling is required for any SVG used as an - [AnonLayout](?path=/docs/component-library-anon-layout--docs) `pageIcon`. +## Note on SVG Icons -7. **Replace any generic `clipPath` ids** (such as `id="a"`) with a unique id, and update the - referencing element to use the new id (such as `clip-path="url(#unique-id-here)"`). - -8. **Import your SVG const** anywhere you want to use the SVG. - - **Angular Component Example:** - - **TypeScript:** - - ```typescript - import { Component } from "@angular/core"; - import { IconModule } from '@bitwarden/components'; - import { ExampleIcon, Example2Icon } from "@bitwarden/assets/svg"; - - @Component({ - selector: "app-example", - standalone: true, - imports: [IconModule], - templateUrl: "./example.component.html", - }) - export class ExampleComponent { - readonly Icons = { ExampleIcon, Example2Icon }; - ... - } - ``` - - - **HTML:** - - > NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an - > `ariaLabel` is explicitly provided to the `` component - - ```html - - ``` - - With `ariaLabel` - - ```html - - ``` - -9. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client - which supports multiple style modes. +For SVG illustrations (not font icons), use the `bit-svg` component instead. See the Svg component +documentation for details. diff --git a/libs/components/src/icon/icon.module.ts b/libs/components/src/icon/icon.module.ts index 3d15b5bb3c3..b3e65619bd3 100644 --- a/libs/components/src/icon/icon.module.ts +++ b/libs/components/src/icon/icon.module.ts @@ -1,9 +1,9 @@ import { NgModule } from "@angular/core"; -import { BitIconComponent } from "./icon.component"; +import { IconComponent } from "./icon.component"; @NgModule({ - imports: [BitIconComponent], - exports: [BitIconComponent], + imports: [IconComponent], + exports: [IconComponent], }) export class IconModule {} diff --git a/libs/components/src/icon/icon.stories.ts b/libs/components/src/icon/icon.stories.ts index e94a7aaf51c..5626407ea51 100644 --- a/libs/components/src/icon/icon.stories.ts +++ b/libs/components/src/icon/icon.stories.ts @@ -1,50 +1,61 @@ -import { Meta } from "@storybook/angular"; +import { Meta, StoryObj } from "@storybook/angular"; -import * as SvgIcons from "@bitwarden/assets/svg"; +import { BITWARDEN_ICONS } from "../shared/icon"; -import { BitIconComponent } from "./icon.component"; +import { IconComponent } from "./icon.component"; export default { title: "Component Library/Icon", - component: BitIconComponent, + component: IconComponent, parameters: { design: { type: "figma", url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-50335&t=k6OTDDPZOTtypRqo-11", }, }, -} as Meta; + argTypes: { + name: { + control: { type: "select" }, + options: BITWARDEN_ICONS, + }, + }, +} as Meta; -const { - // Filtering out the few non-icons in the libs/assets/svg import - // eslint-disable-next-line @typescript-eslint/no-unused-vars - DynamicContentNotAllowedError: _DynamicContentNotAllowedError, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isIcon, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - svgIcon, - ...Icons -}: { - [key: string]: any; -} = SvgIcons; +type Story = StoryObj; -export const Default = { - render: (args: { icons: [string, any][] }) => ({ - props: args, - template: /*html*/ ` -
- @for (icon of icons; track icon[0]) { -
-
{{icon[0]}}
-
- -
-
- } -
- `, - }), +export const Default: Story = { args: { - icons: Object.entries(Icons), + name: "bwi-lock", }, }; + +export const AllIcons: Story = { + render: () => ({ + template: ` +
+ @for (icon of icons; track icon) { +
+ + {{ icon }} +
+ } +
+ `, + props: { + icons: BITWARDEN_ICONS, + }, + }), +}; + +export const WithAriaLabel: Story = { + args: { + name: "bwi-lock", + ariaLabel: "Secure lock icon", + }, +}; + +export const CompareWithLegacy: Story = { + render: () => ({ + template: ` `, + }), +}; diff --git a/libs/components/src/icon/index.ts b/libs/components/src/icon/index.ts index 1ee66e59837..670966a7630 100644 --- a/libs/components/src/icon/index.ts +++ b/libs/components/src/icon/index.ts @@ -1 +1,2 @@ export * from "./icon.module"; +export * from "./icon.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 9c4dadadd4b..d92e0770e49 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -1,4 +1,5 @@ export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction"; +export { BitwardenIcon } from "./shared/icon"; export * from "./a11y"; export * from "./anon-layout"; export * from "./async-actions"; @@ -17,11 +18,11 @@ export * from "./container"; export * from "./copy-click"; export * from "./dialog"; export * from "./disclosure"; -export * from "./drawer"; export * from "./form-field"; export * from "./header"; export * from "./icon-button"; export * from "./icon"; +export * from "./svg"; export * from "./icon-tile"; export * from "./input"; export * from "./item"; diff --git a/libs/components/src/landing-layout/landing-header.component.html b/libs/components/src/landing-layout/landing-header.component.html index ed6d34ef23b..882f1b96c99 100644 --- a/libs/components/src/landing-layout/landing-header.component.html +++ b/libs/components/src/landing-layout/landing-header.component.html @@ -4,7 +4,7 @@ [routerLink]="['/']" class="tw-w-32 tw-py-5 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top" > - + }
diff --git a/libs/components/src/landing-layout/landing-header.component.ts b/libs/components/src/landing-layout/landing-header.component.ts index eb5329e915d..c0fb3cd67f1 100644 --- a/libs/components/src/landing-layout/landing-header.component.ts +++ b/libs/components/src/landing-layout/landing-header.component.ts @@ -3,8 +3,8 @@ import { RouterModule } from "@angular/router"; import { BitwardenLogo } from "@bitwarden/assets/svg"; -import { IconModule } from "../icon"; import { SharedModule } from "../shared"; +import { SvgModule } from "../svg"; /** * Header component for landing pages with optional Bitwarden logo and header actions slot. @@ -34,7 +34,7 @@ import { SharedModule } from "../shared"; selector: "bit-landing-header", changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./landing-header.component.html", - imports: [RouterModule, IconModule, SharedModule], + imports: [RouterModule, SvgModule, SharedModule], }) export class LandingHeaderComponent { readonly hideLogo = input(false); diff --git a/libs/components/src/landing-layout/landing-hero.component.html b/libs/components/src/landing-layout/landing-hero.component.html index dbce6a7c585..9394bb03c63 100644 --- a/libs/components/src/landing-layout/landing-hero.component.html +++ b/libs/components/src/landing-layout/landing-hero.component.html @@ -6,7 +6,7 @@
- +
} diff --git a/libs/components/src/landing-layout/landing-hero.component.ts b/libs/components/src/landing-layout/landing-hero.component.ts index b29e9768efd..d3b9ffd0ee9 100644 --- a/libs/components/src/landing-layout/landing-hero.component.ts +++ b/libs/components/src/landing-layout/landing-hero.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, input } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; -import { IconModule } from "../icon"; +import { SvgModule } from "../svg"; import { TypographyModule } from "../typography"; /** @@ -31,10 +31,10 @@ import { TypographyModule } from "../typography"; selector: "bit-landing-hero", changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./landing-hero.component.html", - imports: [IconModule, TypographyModule], + imports: [SvgModule, TypographyModule], }) export class LandingHeroComponent { - readonly icon = input(null); + readonly icon = input(null); readonly title = input(); readonly subtitle = input(); } diff --git a/libs/components/src/landing-layout/landing-layout.component.html b/libs/components/src/landing-layout/landing-layout.component.html index 1164f538116..a33054e8e64 100644 --- a/libs/components/src/landing-layout/landing-layout.component.html +++ b/libs/components/src/landing-layout/landing-layout.component.html @@ -13,12 +13,12 @@
- +
- +
} diff --git a/libs/components/src/landing-layout/landing-layout.component.ts b/libs/components/src/landing-layout/landing-layout.component.ts index 520cca945d6..65c7302e828 100644 --- a/libs/components/src/landing-layout/landing-layout.component.ts +++ b/libs/components/src/landing-layout/landing-layout.component.ts @@ -3,7 +3,7 @@ import { Component, ChangeDetectionStrategy, inject, input } from "@angular/core import { BackgroundLeftIllustration, BackgroundRightIllustration } from "@bitwarden/assets/svg"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { IconModule } from "../icon"; +import { SvgModule } from "../svg"; /** * Root layout component for landing pages providing a full-screen container with optional decorative background illustrations. @@ -27,7 +27,7 @@ import { IconModule } from "../icon"; selector: "bit-landing-layout", changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./landing-layout.component.html", - imports: [IconModule], + imports: [SvgModule], }) export class LandingLayoutComponent { readonly hideBackgroundIllustration = input(false); diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index 66bfcafafe9..f0e2b601e38 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -30,21 +30,14 @@ - @if ( - { - open: sideNavService.open$ | async, - }; - as data - ) { -
- @if (data.open) { -
- } -
- } +
+ @if (sideNavService.open()) { +
+ } +
diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts index 5e3d420c8e5..da30b76a9f0 100644 --- a/libs/components/src/layout/layout.component.ts +++ b/libs/components/src/layout/layout.component.ts @@ -4,8 +4,7 @@ import { CommonModule } from "@angular/common"; import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { DrawerHostDirective } from "../drawer/drawer-host.directive"; -import { DrawerService } from "../drawer/drawer.service"; +import { DrawerService } from "../dialog/drawer.service"; import { LinkModule } from "../link"; import { SideNavService } from "../navigation/side-nav.service"; import { SharedModule } from "../shared"; @@ -31,7 +30,6 @@ import { ScrollLayoutHostDirective } from "./scroll-layout.directive"; "(document:keydown.tab)": "handleKeydown($event)", class: "tw-block tw-h-screen", }, - hostDirectives: [DrawerHostDirective], }) export class LayoutComponent { protected sideNavService = inject(SideNavService); diff --git a/libs/components/src/navigation/nav-base.component.ts b/libs/components/src/navigation/nav-base.component.ts index 706df2b25ad..e20edf5a0f9 100644 --- a/libs/components/src/navigation/nav-base.component.ts +++ b/libs/components/src/navigation/nav-base.component.ts @@ -1,8 +1,11 @@ -import { Directive, EventEmitter, Output, input, model } from "@angular/core"; +import { Directive, output, input, model } from "@angular/core"; import { RouterLink, RouterLinkActive } from "@angular/router"; /** - * `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties that are passed down to `NavItemComponent`. + * Base class for navigation components in the side navigation. + * + * `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties + * that are passed down to `NavItemComponent`. */ @Directive() export abstract class NavBaseComponent { @@ -38,23 +41,26 @@ export abstract class NavBaseComponent { * * --- * + * @remarks * We can't name this "routerLink" because Angular will mount the `RouterLink` directive. * - * See: {@link https://github.com/angular/angular/issues/24482} + * @see {@link RouterLink.routerLink} + * @see {@link https://github.com/angular/angular/issues/24482} */ readonly route = input(); /** * Passed to internal `routerLink` * - * See {@link RouterLink.relativeTo} + * @see {@link RouterLink.relativeTo} */ readonly relativeTo = input(); /** * Passed to internal `routerLink` * - * See {@link RouterLinkActive.routerLinkActiveOptions} + * @default { paths: "subset", queryParams: "ignored", fragment: "ignored", matrixParams: "ignored" } + * @see {@link RouterLinkActive.routerLinkActiveOptions} */ readonly routerLinkActiveOptions = input({ paths: "subset", @@ -71,7 +77,5 @@ export abstract class NavBaseComponent { /** * Fires when main content is clicked */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref - @Output() mainContentClicked: EventEmitter = new EventEmitter(); + readonly mainContentClicked = output(); } diff --git a/libs/components/src/navigation/nav-divider.component.html b/libs/components/src/navigation/nav-divider.component.html index 2d8e1dfa24b..7af7de2a28a 100644 --- a/libs/components/src/navigation/nav-divider.component.html +++ b/libs/components/src/navigation/nav-divider.component.html @@ -1,3 +1,3 @@ -@if (sideNavService.open$ | async) { +@if (sideNavService.open()) {
} diff --git a/libs/components/src/navigation/nav-divider.component.ts b/libs/components/src/navigation/nav-divider.component.ts index 2f33883fd58..05a69563312 100644 --- a/libs/components/src/navigation/nav-divider.component.ts +++ b/libs/components/src/navigation/nav-divider.component.ts @@ -1,15 +1,15 @@ -import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; import { SideNavService } from "./side-nav.service"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +/** + * A visual divider for separating navigation items in the side navigation. + */ @Component({ selector: "bit-nav-divider", templateUrl: "./nav-divider.component.html", - imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavDividerComponent { - constructor(protected sideNavService: SideNavService) {} + protected readonly sideNavService = inject(SideNavService); } diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index d305f89063e..26d1c68da43 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -20,9 +20,7 @@ - + } @if (open) {
}
+ + + +
+ @if (icon()) { + + } + @if (open) { + {{ text() }} + } +
+
diff --git a/libs/components/src/navigation/nav-item.component.ts b/libs/components/src/navigation/nav-item.component.ts index e57413d9980..53b181ec083 100644 --- a/libs/components/src/navigation/nav-item.component.ts +++ b/libs/components/src/navigation/nav-item.component.ts @@ -1,7 +1,14 @@ -import { CommonModule } from "@angular/common"; -import { Component, HostListener, Optional, computed, input, model } from "@angular/core"; -import { RouterLinkActive, RouterModule } from "@angular/router"; -import { BehaviorSubject, map } from "rxjs"; +import { NgTemplateOutlet } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + input, + inject, + signal, + computed, + model, +} from "@angular/core"; +import { RouterModule, RouterLinkActive } from "@angular/router"; import { IconButtonModule } from "../icon-button"; @@ -14,13 +21,16 @@ export abstract class NavGroupAbstraction { abstract treeDepth: ReturnType>; } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-nav-item", templateUrl: "./nav-item.component.html", providers: [{ provide: NavBaseComponent, useExisting: NavItemComponent }], - imports: [CommonModule, IconButtonModule, RouterModule], + imports: [NgTemplateOutlet, IconButtonModule, RouterModule], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "(focusin)": "onFocusIn($event.target)", + "(focusout)": "onFocusOut()", + }, }) export class NavItemComponent extends NavBaseComponent { /** @@ -35,9 +45,14 @@ export class NavItemComponent extends NavBaseComponent { */ protected readonly TREE_DEPTH_PADDING = 1.75; - /** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */ + /** + * Forces active styles to be shown, regardless of the `routerLinkActiveOptions` + */ readonly forceActiveStyles = input(false); + protected readonly sideNavService = inject(SideNavService); + private readonly parentNavGroup = inject(NavGroupAbstraction, { optional: true }); + /** * Is `true` if `to` matches the current route */ @@ -56,7 +71,7 @@ export class NavItemComponent extends NavBaseComponent { * adding calculation for tree variant due to needing visual alignment on different indentation levels needed between the first level and subsequent levels */ protected readonly navItemIndentationPadding = computed(() => { - const open = this.sideNavService.open; + const open = this.sideNavService.open(); const depth = this.treeDepth() ?? 0; if (open && this.variant() === "tree") { @@ -87,25 +102,22 @@ export class NavItemComponent extends NavBaseComponent { * (denoted with the data-fvw attribute) matches :focus-visible. We then map that state to some * styles, so the entire component can have an outline. */ - protected focusVisibleWithin$ = new BehaviorSubject(false); - protected fvwStyles$ = this.focusVisibleWithin$.pipe( - map((value) => - value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus" : "", - ), + protected readonly focusVisibleWithin = signal(false); + protected readonly fvwStyles = computed(() => + this.focusVisibleWithin() + ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus" + : "", ); - @HostListener("focusin", ["$event.target"]) - onFocusIn(target: HTMLElement) { - this.focusVisibleWithin$.next(target.matches("[data-fvw]:focus-visible")); - } - @HostListener("focusout") - onFocusOut() { - this.focusVisibleWithin$.next(false); + + protected onFocusIn(target: HTMLElement) { + this.focusVisibleWithin.set(target.matches("[data-fvw]:focus-visible")); } - constructor( - protected sideNavService: SideNavService, - @Optional() private parentNavGroup: NavGroupAbstraction, - ) { + protected onFocusOut() { + this.focusVisibleWithin.set(false); + } + + constructor() { super(); // Set tree depth based on parent's depth diff --git a/libs/components/src/navigation/nav-logo.component.html b/libs/components/src/navigation/nav-logo.component.html index 1d9961554c2..8323a0f3479 100644 --- a/libs/components/src/navigation/nav-logo.component.html +++ b/libs/components/src/navigation/nav-logo.component.html @@ -1,22 +1,21 @@ diff --git a/libs/components/src/navigation/nav-logo.component.ts b/libs/components/src/navigation/nav-logo.component.ts index 0602e8b753c..4b3dc471edb 100644 --- a/libs/components/src/navigation/nav-logo.component.ts +++ b/libs/components/src/navigation/nav-logo.component.ts @@ -1,34 +1,40 @@ -import { CommonModule } from "@angular/common"; -import { Component, input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, inject } from "@angular/core"; import { RouterLinkActive, RouterLink } from "@angular/router"; -import { BitwardenShield, Icon } from "@bitwarden/assets/svg"; +import { BitwardenShield, BitSvg } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "../icon/icon.component"; +import { SvgComponent } from "../svg/svg.component"; import { SideNavService } from "./side-nav.service"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-nav-logo", templateUrl: "./nav-logo.component.html", - imports: [CommonModule, RouterLinkActive, RouterLink, BitIconComponent], + imports: [RouterLinkActive, RouterLink, SvgComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavLogoComponent { - /** Icon that is displayed when the side nav is closed */ + protected readonly sideNavService = inject(SideNavService); + + /** + * Icon that is displayed when the side nav is closed + * + * @default BitwardenShield + */ readonly closedIcon = input(BitwardenShield); - /** Icon that is displayed when the side nav is open */ - readonly openIcon = input.required(); + /** + * Icon that is displayed when the side nav is open + */ + readonly openIcon = input.required(); /** * Route to be passed to internal `routerLink` */ readonly route = input.required(); - /** Passed to `attr.aria-label` and `attr.title` */ + /** + * Passed to `attr.aria-label` and `attr.title` + */ readonly label = input.required(); - - constructor(protected sideNavService: SideNavService) {} } diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index b70d650622a..78fed07011d 100644 --- a/libs/components/src/navigation/side-nav.component.html +++ b/libs/components/src/navigation/side-nav.component.html @@ -1,68 +1,60 @@ -@if ( - { - open: sideNavService.open$ | async, - isOverlay: sideNavService.isOverlay$ | async, - }; - as data -) { -
- +@let open = sideNavService.open(); +@let isOverlay = sideNavService.isOverlay(); + +
+
-} + class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav" + > + + @if (open) { + + } +
+ +
+
+ + +
diff --git a/libs/components/src/navigation/side-nav.component.ts b/libs/components/src/navigation/side-nav.component.ts index b13920d9749..35835f1be96 100644 --- a/libs/components/src/navigation/side-nav.component.ts +++ b/libs/components/src/navigation/side-nav.component.ts @@ -1,7 +1,14 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { DragDropModule, CdkDragMove } from "@angular/cdk/drag-drop"; -import { CommonModule } from "@angular/common"; -import { Component, ElementRef, inject, input, viewChild } from "@angular/core"; +import { AsyncPipe } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + input, + viewChild, + inject, +} from "@angular/core"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -12,35 +19,42 @@ import { SideNavService } from "./side-nav.service"; export type SideNavVariant = "primary" | "secondary"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +/** + * Side navigation component that provides a collapsible navigation menu. + */ @Component({ selector: "bit-side-nav", templateUrl: "side-nav.component.html", imports: [ - CommonModule, CdkTrapFocus, NavDividerComponent, BitIconButtonComponent, I18nPipe, DragDropModule, + AsyncPipe, ], host: { class: "tw-block tw-h-full", }, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SideNavComponent { - protected sideNavService = inject(SideNavService); + protected readonly sideNavService = inject(SideNavService); + /** + * Visual variant of the side navigation + * + * @default "primary" + */ readonly variant = input("primary"); private readonly toggleButton = viewChild("toggleButton", { read: ElementRef }); private elementRef = inject>(ElementRef); - protected handleKeyDown = (event: KeyboardEvent) => { + protected readonly handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - this.sideNavService.setClose(); + this.sideNavService.open.set(false); this.toggleButton()?.nativeElement.focus(); return false; } diff --git a/libs/components/src/navigation/side-nav.service.ts b/libs/components/src/navigation/side-nav.service.ts index 63e54c81fe5..05713006a43 100644 --- a/libs/components/src/navigation/side-nav.service.ts +++ b/libs/components/src/navigation/side-nav.service.ts @@ -1,15 +1,6 @@ -import { inject, Injectable } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { - BehaviorSubject, - Observable, - combineLatest, - fromEvent, - map, - startWith, - debounceTime, - first, -} from "rxjs"; +import { computed, effect, inject, Injectable, signal } from "@angular/core"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { BehaviorSubject, Observable, fromEvent, map, startWith, debounceTime, first } from "rxjs"; import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state"; @@ -32,16 +23,17 @@ export class SideNavService { private rootFontSizePx: number; - private _open$ = new BehaviorSubject(isAtOrLargerThanBreakpoint("md")); - open$ = this._open$.asObservable(); + /** + * Whether the side navigation is open or closed. + */ + readonly open = signal(isAtOrLargerThanBreakpoint("md")); private isLargeScreen$ = media(`(min-width: ${BREAKPOINTS.md}px)`); - private _userCollapsePreference$ = new BehaviorSubject(null); - userCollapsePreference$ = this._userCollapsePreference$.asObservable(); + readonly isLargeScreen = toSignal(this.isLargeScreen$, { requireSync: true }); - isOverlay$ = combineLatest([this.open$, this.isLargeScreen$]).pipe( - map(([open, isLargeScreen]) => open && !isLargeScreen), - ); + readonly userCollapsePreference = signal(null); + + readonly isOverlay = computed(() => this.open() && !this.isLargeScreen()); /** * Local component state width @@ -67,16 +59,14 @@ export class SideNavService { this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16"); // Handle open/close state - combineLatest([this.isLargeScreen$, this.userCollapsePreference$]) - .pipe(takeUntilDestroyed()) - .subscribe(([isLargeScreen, userCollapsePreference]) => { - if (!isLargeScreen) { - this.setClose(); - } else if (userCollapsePreference !== "closed") { - // Auto-open when user hasn't set preference (null) or prefers open - this.setOpen(); - } - }); + effect(() => { + if (!this.isLargeScreen()) { + this.open.set(false); + } else if (this.userCollapsePreference() !== "closed") { + // Auto-open when user hasn't set preference (null) or prefers open + this.open.set(true); + } + }); // Initialize the resizable width from state provider this.widthState$.pipe(first()).subscribe((width: number) => { @@ -89,31 +79,14 @@ export class SideNavService { }); } - get open() { - return this._open$.getValue(); - } - - setOpen() { - this._open$.next(true); - } - - setClose() { - this._open$.next(false); - } - /** * Toggle the open/close state of the side nav */ toggle() { - const curr = this._open$.getValue(); // Store user's preference based on what state they're toggling TO - this._userCollapsePreference$.next(curr ? "closed" : "open"); + this.userCollapsePreference.set(this.open() ? "closed" : "open"); - if (curr) { - this.setClose(); - } else { - this.setOpen(); - } + this.open.set(!this.open()); } /** diff --git a/libs/components/src/no-items/no-items.component.html b/libs/components/src/no-items/no-items.component.html index e728584a41a..46a5c25526a 100644 --- a/libs/components/src/no-items/no-items.component.html +++ b/libs/components/src/no-items/no-items.component.html @@ -1,7 +1,7 @@
- +

diff --git a/libs/components/src/no-items/no-items.component.ts b/libs/components/src/no-items/no-items.component.ts index c6e52a1f83d..d2cacfd2251 100644 --- a/libs/components/src/no-items/no-items.component.ts +++ b/libs/components/src/no-items/no-items.component.ts @@ -1,18 +1,17 @@ -import { Component, input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { NoResults } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "../icon/icon.component"; +import { SvgComponent } from "../svg/svg.component"; /** * Component for displaying a message when there are no items to display. Expects title, description and button slots. */ -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-no-items", templateUrl: "./no-items.component.html", - imports: [BitIconComponent], + imports: [SvgComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NoItemsComponent { readonly icon = input(NoResults); diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts index c4fe2f9b2af..1b2c7cec5da 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts @@ -13,10 +13,8 @@ import { CalloutModule } from "../../callout"; import { CheckboxModule } from "../../checkbox"; import { ColorPasswordModule } from "../../color-password"; import { DialogModule } from "../../dialog"; -import { DrawerModule } from "../../drawer"; import { FormControlModule } from "../../form-control"; import { FormFieldModule } from "../../form-field"; -import { IconModule } from "../../icon"; import { IconButtonModule } from "../../icon-button"; import { InputModule } from "../../input"; import { LayoutComponent } from "../../layout"; @@ -31,6 +29,7 @@ import { SearchModule } from "../../search"; import { SectionComponent } from "../../section"; import { SelectModule } from "../../select"; import { SharedModule } from "../../shared"; +import { SvgModule } from "../../svg"; import { TableModule } from "../../table"; import { TabsModule } from "../../tabs"; import { ToggleGroupModule } from "../../toggle-group"; @@ -49,12 +48,11 @@ import { TypographyModule } from "../../typography"; ColorPasswordModule, CommonModule, DialogModule, - DrawerModule, FormControlModule, FormFieldModule, FormsModule, IconButtonModule, - IconModule, + SvgModule, InputModule, LayoutComponent, LinkModule, @@ -87,12 +85,11 @@ import { TypographyModule } from "../../typography"; ColorPasswordModule, CommonModule, DialogModule, - DrawerModule, FormControlModule, FormFieldModule, FormsModule, IconButtonModule, - IconModule, + SvgModule, InputModule, LayoutComponent, LinkModule, diff --git a/libs/components/src/svg/index.ts b/libs/components/src/svg/index.ts new file mode 100644 index 00000000000..ae4c480e786 --- /dev/null +++ b/libs/components/src/svg/index.ts @@ -0,0 +1,2 @@ +export * from "./svg.module"; +export * from "./svg.component"; diff --git a/libs/components/src/svg/svg.component.ts b/libs/components/src/svg/svg.component.ts new file mode 100644 index 00000000000..bcb63cfa568 --- /dev/null +++ b/libs/components/src/svg/svg.component.ts @@ -0,0 +1,31 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from "@angular/core"; +import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; + +import { BitSvg, isBitSvg } from "@bitwarden/assets/svg"; + +@Component({ + selector: "bit-svg", + host: { + "[attr.aria-hidden]": "!ariaLabel()", + "[attr.aria-label]": "ariaLabel()", + "[innerHtml]": "innerHtml()", + class: "tw-max-h-full tw-flex tw-justify-center", + }, + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SvgComponent { + private domSanitizer = inject(DomSanitizer); + + readonly content = input(); + readonly ariaLabel = input(); + + protected readonly innerHtml = computed(() => { + const content = this.content(); + if (!isBitSvg(content)) { + return null; + } + const svg = content.svg; + return this.domSanitizer.bypassSecurityTrustHtml(svg); + }); +} diff --git a/libs/components/src/icon/icon.components.spec.ts b/libs/components/src/svg/svg.components.spec.ts similarity index 55% rename from libs/components/src/icon/icon.components.spec.ts rename to libs/components/src/svg/svg.components.spec.ts index 3ae37ff5423..55874d29e6c 100644 --- a/libs/components/src/icon/icon.components.spec.ts +++ b/libs/components/src/svg/svg.components.spec.ts @@ -1,25 +1,25 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { Icon, svgIcon } from "@bitwarden/assets/svg"; +import { BitSvg, svg } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "./icon.component"; +import { SvgComponent } from "./svg.component"; -describe("IconComponent", () => { - let fixture: ComponentFixture; +describe("SvgComponent", () => { + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [BitIconComponent], + imports: [SvgComponent], }).compileComponents(); - fixture = TestBed.createComponent(BitIconComponent); + fixture = TestBed.createComponent(SvgComponent); fixture.detectChanges(); }); it("should have empty innerHtml when input is not an Icon", () => { - const fakeIcon = { svg: "harmful user input" } as Icon; + const fakeIcon = { svg: "harmful user input" } as BitSvg; - fixture.componentRef.setInput("icon", fakeIcon); + fixture.componentRef.setInput("content", fakeIcon); fixture.detectChanges(); const el = fixture.nativeElement as HTMLElement; @@ -27,9 +27,9 @@ describe("IconComponent", () => { }); it("should contain icon when input is a safe Icon", () => { - const icon = svgIcon`safe icon`; + const icon = svg`safe icon`; - fixture.componentRef.setInput("icon", icon); + fixture.componentRef.setInput("content", icon); fixture.detectChanges(); const el = fixture.nativeElement as HTMLElement; diff --git a/libs/components/src/svg/svg.mdx b/libs/components/src/svg/svg.mdx new file mode 100644 index 00000000000..a29a6f86b14 --- /dev/null +++ b/libs/components/src/svg/svg.mdx @@ -0,0 +1,120 @@ +import { Meta, Story, Controls } from "@storybook/addon-docs/blocks"; + +import * as stories from "./svg.stories"; + + + +```ts +import { SvgModule } from "@bitwarden/components"; +``` + +# Svg Use Instructions + +- Icons will generally be attached to the associated Jira task. + - Designers should minify any SVGs before attaching them to Jira using a tool like + [SVGOMG](https://jakearchibald.github.io/svgomg/). + - **Note:** Ensure the "Remove viewbox" option is toggled off if responsive resizing of the icon + is desired. + +## Developer Instructions + +1. **Download the SVG** and import it as an `.svg` initially into the IDE of your choice. + - The SVG should be formatted using either a built-in formatter or an external tool like + [SVG Formatter Beautifier](https://codebeautify.org/svg-formatter-beautifier) to make applying + classes easier. + +2. **Rename the file** as a `.icon.ts` TypeScript file and place it in the `libs/assets/svg` + lib. + +3. **Import** `svg` from `./svg`. + +4. **Define and export** a `const` to represent your `svg`. + + ```typescript + export const ExampleIcon = svg``; + ``` + +5. **Replace any hardcoded strokes or fills** with the appropriate Tailwind class. + - **Note:** Stroke is used when styling the outline of an SVG path, while fill is used when + styling the inside of an SVG path. + + - A non-comprehensive list of common colors and their associated classes is below: + + | Hardcoded Value | Tailwind Stroke Class | Tailwind Fill Class | Tailwind Variable | + | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------- | ----------------------------------- | + | `#020F66` | `tw-stroke-illustration-outline` | `tw-fill-illustration-outline` | `--color-illustration-outline` | + | `#DBE5F6` | `tw-stroke-illustration-bg-primary` | `tw-fill-illustration-bg-primary` | `--color-illustration-bg-primary` | + | `#AAC3EF` | `tw-stroke-illustration-bg-secondary` | `tw-fill-illustration-bg-secondary` | `--color-illustration-bg-secondary` | + | `#FFFFFF` | `tw-stroke-illustration-bg-tertiary` | `tw-fill-illustration-bg-tertiary` | `--color-illustration-bg-tertiary` | + | `#FFBF00` | `tw-stroke-illustration-tertiary` | `tw-fill-illustration-tertiary` | `--color-illustration-tertiary` | + | `#175DDC` | `tw-stroke-illustration-logo` | `tw-fill-illustration-logo` | `--color-illustration-logo` | + + - If the hex that you have on an SVG path is not listed above, there are a few ways to figure out + the appropriate Tailwind class: + - **Option 1: Figma** + - Open the SVG in Figma. + - Click on an individual path on the SVG until you see the path's properties in the + right-hand panel. + - Scroll down to the Colors section. + - Example: `Color/Illustration/Outline` + - This also includes Hex or RGB values that can be used to find the appropriate Tailwind + variable as well if you follow the manual search option below. + - Create the appropriate stroke or fill class from the color used. + - Example: `Color/Illustration/Outline` corresponds to `--color-illustration-outline` which + corresponds to `tw-stroke-illustration-outline` or `tw-fill-illustration-outline`. + - **Option 2: Manual Search** + - Take the path's stroke or fill hex value and convert it to RGB using a tool like + [Hex to RGB](https://www.rgbtohex.net/hex-to-rgb/). + - Search for the RGB value without commas in our `tw-theme.css` to find the Tailwind variable + that corresponds to the color. + - Create the appropriate stroke or fill class using the Tailwind variable. + - Example: `--color-illustration-outline` corresponds to `tw-stroke-illustration-outline` + or `tw-fill-illustration-outline`. + +6. **Remove any hardcoded width or height attributes** if your SVG has a configured + [viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) attribute in order + to allow the SVG to scale to fit its container. + - **Note:** Scaling is required for any SVG used as an + [AnonLayout](?path=/docs/component-library-anon-layout--docs) `pageIcon`. + +7. **Replace any generic `clipPath` ids** (such as `id="a"`) with a unique id, and update the + referencing element to use the new id (such as `clip-path="url(#unique-id-here)"`). + +8. **Import your SVG const** anywhere you want to use the SVG. + - **Angular Component Example:** + - **TypeScript:** + + ```typescript + import { Component } from "@angular/core"; + import { SvgModule } from '@bitwarden/components'; + import { ExampleIcon, Example2Icon } from "@bitwarden/assets/svg"; + + @Component({ + selector: "app-example", + standalone: true, + imports: [SvgModule], + templateUrl: "./example.component.html", + }) + export class ExampleComponent { + readonly Icons = { ExampleIcon, Example2Icon }; + ... + } + ``` + + - **HTML:** + + > NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an + > `ariaLabel` is explicitly provided to the `` component + + ```html + + ``` + + With `ariaLabel` + + ```html + + ``` + +9. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client + which supports multiple style modes. diff --git a/libs/components/src/svg/svg.module.ts b/libs/components/src/svg/svg.module.ts new file mode 100644 index 00000000000..c1cdae0e232 --- /dev/null +++ b/libs/components/src/svg/svg.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from "@angular/core"; + +import { SvgComponent } from "./svg.component"; + +@NgModule({ + imports: [SvgComponent], + exports: [SvgComponent], +}) +export class SvgModule {} diff --git a/libs/components/src/svg/svg.stories.ts b/libs/components/src/svg/svg.stories.ts new file mode 100644 index 00000000000..b2eb10771ce --- /dev/null +++ b/libs/components/src/svg/svg.stories.ts @@ -0,0 +1,50 @@ +import { Meta } from "@storybook/angular"; + +import * as SvgIcons from "@bitwarden/assets/svg"; + +import { SvgComponent } from "./svg.component"; + +export default { + title: "Component Library/Svg", + component: SvgComponent, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-50335&t=k6OTDDPZOTtypRqo-11", + }, + }, +} as Meta; + +const { + // Filtering out the few non-icons in the libs/assets/svg import + // eslint-disable-next-line @typescript-eslint/no-unused-vars + DynamicContentNotAllowedError: _DynamicContentNotAllowedError, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isBitSvg, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + svg, + ...Icons +}: { + [key: string]: any; +} = SvgIcons; + +export const Default = { + render: (args: { icons: [string, any][] }) => ({ + props: args, + template: /*html*/ ` +
+ @for (icon of icons; track icon[0]) { +
+
{{icon[0]}}
+
+ +
+
+ } +
+ `, + }), + args: { + icons: Object.entries(Icons), + }, +}; diff --git a/libs/eslint/components/index.mjs b/libs/eslint/components/index.mjs index 273c29890fe..101fdde414c 100644 --- a/libs/eslint/components/index.mjs +++ b/libs/eslint/components/index.mjs @@ -1,9 +1,11 @@ import requireLabelOnBiticonbutton from "./require-label-on-biticonbutton.mjs"; import requireThemeColorsInSvg from "./require-theme-colors-in-svg.mjs"; +import noBwiClassUsage from "./no-bwi-class-usage.mjs"; export default { rules: { "require-label-on-biticonbutton": requireLabelOnBiticonbutton, "require-theme-colors-in-svg": requireThemeColorsInSvg, + "no-bwi-class-usage": noBwiClassUsage, }, }; diff --git a/libs/eslint/components/no-bwi-class-usage.mjs b/libs/eslint/components/no-bwi-class-usage.mjs new file mode 100644 index 00000000000..8260587ce45 --- /dev/null +++ b/libs/eslint/components/no-bwi-class-usage.mjs @@ -0,0 +1,45 @@ +export const errorMessage = + "Use component instead of applying 'bwi' classes directly. Example: "; + +export default { + meta: { + type: "suggestion", + docs: { + description: + "Discourage using 'bwi' font icon classes directly in favor of the component", + category: "Best Practices", + recommended: true, + }, + schema: [], + }, + create(context) { + return { + Element(node) { + // Get all class-related attributes + const classAttrs = [ + ...(node.attributes?.filter((attr) => attr.name === "class") ?? []), + ...(node.inputs?.filter((input) => input.name === "class") ?? []), + ...(node.templateAttrs?.filter((attr) => attr.name === "class") ?? []), + ]; + + for (const classAttr of classAttrs) { + const classValue = classAttr.value || ""; + + // Check if the class value contains 'bwi' or 'bwi-' + // This handles both string literals and template expressions + const hasBwiClass = + typeof classValue === "string" && /\bbwi(?:-[\w-]+)?\b/.test(classValue); + + if (hasBwiClass) { + context.report({ + node, + message: errorMessage, + }); + // Only report once per element + break; + } + } + }, + }; + }, +}; diff --git a/libs/eslint/components/no-bwi-class-usage.spec.mjs b/libs/eslint/components/no-bwi-class-usage.spec.mjs new file mode 100644 index 00000000000..abb5ebe3b29 --- /dev/null +++ b/libs/eslint/components/no-bwi-class-usage.spec.mjs @@ -0,0 +1,44 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; + +import rule, { errorMessage } from "./no-bwi-class-usage.mjs"; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: require("@angular-eslint/template-parser"), + }, +}); + +ruleTester.run("no-bwi-class-usage", rule.default, { + valid: [ + { + name: "should allow bit-icon component usage", + code: ``, + }, + { + name: "should allow elements without bwi classes", + code: `
`, + }, + ], + invalid: [ + { + name: "should error on direct bwi class usage", + code: ``, + errors: [{ message: errorMessage }], + }, + { + name: "should error on bwi class with other classes", + code: ``, + errors: [{ message: errorMessage }], + }, + { + name: "should error on single bwi-* class", + code: ``, + errors: [{ message: errorMessage }], + }, + { + name: "should error on bwi-fw modifier", + code: ``, + errors: [{ message: errorMessage }], + }, + ], +}); diff --git a/libs/eslint/components/require-theme-colors-in-svg.mjs b/libs/eslint/components/require-theme-colors-in-svg.mjs index fcc9cba461c..d30840710ca 100644 --- a/libs/eslint/components/require-theme-colors-in-svg.mjs +++ b/libs/eslint/components/require-theme-colors-in-svg.mjs @@ -25,7 +25,7 @@ export default { tagNames: { type: "array", items: { type: "string" }, - default: ["svgIcon"], + default: ["svg"], }, }, additionalProperties: false, @@ -35,7 +35,7 @@ export default { create(context) { const options = context.options[0] || {}; - const tagNames = options.tagNames || ["svgIcon"]; + const tagNames = options.tagNames || ["svg"]; function isSvgTaggedTemplate(node) { return ( diff --git a/libs/eslint/components/require-theme-colors-in-svg.spec.mjs b/libs/eslint/components/require-theme-colors-in-svg.spec.mjs index fd513ba57b3..f51871fdc9a 100644 --- a/libs/eslint/components/require-theme-colors-in-svg.spec.mjs +++ b/libs/eslint/components/require-theme-colors-in-svg.spec.mjs @@ -17,36 +17,36 @@ ruleTester.run("require-theme-colors-in-svg", rule.default, { valid: [ { name: "Allows fill=none", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', }, { name: "Allows CSS variable", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', }, { name: "Allows class-based coloring", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', }, ], invalid: [ { name: "Errors on fill with hex color", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "#000000" } }], }, { name: "Errors on stroke with named color", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "red" } }], }, { name: "Errors on fill with rgb()", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "rgb(255,0,0)" } }], }, { name: "Errors on fill with named color", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "blue" } }], }, ], diff --git a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts index e66779f0372..8f1a281050f 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts @@ -91,4 +91,33 @@ describe("BitwardenCsvImporter", () => { expect(result.collections[0].name).toBe("collection1/collection2"); expect(result.collections[1].name).toBe("collection1"); }); + + it("should parse archived items correctly", async () => { + const archivedDate = "2025-01-15T10:30:00.000Z"; + const data = + `name,type,archivedDate,login_uri,login_username,login_password` + + `\nArchived Login,login,${archivedDate},https://example.com,user,pass`; + + importer.organizationId = null; + const result = await importer.parse(data); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + + const cipher = result.ciphers[0]; + expect(cipher.name).toBe("Archived Login"); + expect(cipher.archivedDate).toBeDefined(); + expect(cipher.archivedDate.toISOString()).toBe(archivedDate); + }); + + it("should handle missing archivedDate gracefully", async () => { + const data = `name,type,login_uri` + `\nTest Login,login,https://example.com`; + + importer.organizationId = null; + const result = await importer.parse(data); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + expect(result.ciphers[0].archivedDate).toBeUndefined(); + }); }); diff --git a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.ts index b900e9e8d7a..cca1b80e3bd 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.ts @@ -51,6 +51,15 @@ export class BitwardenCsvImporter extends BaseImporter implements Importer { cipher.reprompt = CipherRepromptType.None; } + if (!this.isNullOrWhitespace(value.archivedDate)) { + try { + cipher.archivedDate = new Date(value.archivedDate); + } catch (e) { + // eslint-disable-next-line + console.error("Unable to parse archivedDate value", e); + } + } + if (!this.isNullOrWhitespace(value.fields)) { const fields = this.splitNewLine(value.fields); for (let i = 0; i < fields.length; i++) { diff --git a/libs/importer/src/importers/buttercup-csv-importer.spec.ts b/libs/importer/src/importers/buttercup-csv-importer.spec.ts new file mode 100644 index 00000000000..51c9d4cb2d8 --- /dev/null +++ b/libs/importer/src/importers/buttercup-csv-importer.spec.ts @@ -0,0 +1,87 @@ +import { ButtercupCsvImporter } from "./buttercup-csv-importer"; +import { + buttercupCsvTestData, + buttercupCsvWithCustomFieldsTestData, + buttercupCsvWithNoteTestData, + buttercupCsvWithSubfoldersTestData, + buttercupCsvWithUrlFieldTestData, +} from "./spec-data/buttercup-csv/testdata.csv"; + +describe("Buttercup CSV Importer", () => { + let importer: ButtercupCsvImporter; + + beforeEach(() => { + importer = new ButtercupCsvImporter(); + }); + + describe("given basic login data", () => { + it("should parse login data when provided valid CSV", async () => { + const result = await importer.parse(buttercupCsvTestData); + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(2); + + const cipher = result.ciphers[0]; + expect(cipher.name).toEqual("Test Entry"); + expect(cipher.login.username).toEqual("testuser"); + expect(cipher.login.password).toEqual("testpass123"); + expect(cipher.login.uris.length).toEqual(1); + expect(cipher.login.uris[0].uri).toEqual("https://example.com"); + }); + + it("should assign entries to folders based on group_name", async () => { + const result = await importer.parse(buttercupCsvTestData); + expect(result.success).toBe(true); + expect(result.folders.length).toBe(1); + expect(result.folders[0].name).toEqual("General"); + expect(result.folderRelationships.length).toBe(2); + }); + }); + + describe("given URL field variations", () => { + it("should handle lowercase url field", async () => { + const result = await importer.parse(buttercupCsvWithUrlFieldTestData); + expect(result.success).toBe(true); + + const cipher = result.ciphers[0]; + expect(cipher.login.uris.length).toEqual(1); + expect(cipher.login.uris[0].uri).toEqual("https://lowercase-url.com"); + }); + }); + + describe("given note field", () => { + it("should map note field to notes", async () => { + const result = await importer.parse(buttercupCsvWithNoteTestData); + expect(result.success).toBe(true); + + const cipher = result.ciphers[0]; + expect(cipher.notes).toEqual("This is a note"); + }); + }); + + describe("given custom fields", () => { + it("should import custom fields and exclude official props", async () => { + const result = await importer.parse(buttercupCsvWithCustomFieldsTestData); + expect(result.success).toBe(true); + + const cipher = result.ciphers[0]; + expect(cipher.fields.length).toBe(2); + expect(cipher.fields[0].name).toEqual("custom_field"); + expect(cipher.fields[0].value).toEqual("custom value"); + expect(cipher.fields[1].name).toEqual("another_field"); + expect(cipher.fields[1].value).toEqual("another value"); + }); + }); + + describe("given subfolders", () => { + it("should create nested folder structure", async () => { + const result = await importer.parse(buttercupCsvWithSubfoldersTestData); + expect(result.success).toBe(true); + + const folderNames = result.folders.map((f) => f.name); + expect(folderNames).toContain("Work/Projects"); + expect(folderNames).toContain("Work"); + expect(folderNames).toContain("Personal/Finance"); + expect(folderNames).toContain("Personal"); + }); + }); +}); diff --git a/libs/importer/src/importers/buttercup-csv-importer.ts b/libs/importer/src/importers/buttercup-csv-importer.ts index ac3a4cd2512..07fe53bc625 100644 --- a/libs/importer/src/importers/buttercup-csv-importer.ts +++ b/libs/importer/src/importers/buttercup-csv-importer.ts @@ -3,7 +3,18 @@ import { ImportResult } from "../models/import-result"; import { BaseImporter } from "./base-importer"; import { Importer } from "./importer"; -const OfficialProps = ["!group_id", "!group_name", "title", "username", "password", "URL", "id"]; +const OfficialProps = [ + "!group_id", + "!group_name", + "!type", + "title", + "username", + "password", + "URL", + "url", + "note", + "id", +]; export class ButtercupCsvImporter extends BaseImporter implements Importer { parse(data: string): Promise { @@ -21,16 +32,24 @@ export class ButtercupCsvImporter extends BaseImporter implements Importer { cipher.name = this.getValueOrDefault(value.title, "--"); cipher.login.username = this.getValueOrDefault(value.username); cipher.login.password = this.getValueOrDefault(value.password); - cipher.login.uris = this.makeUriArray(value.URL); - let processingCustomFields = false; + // Handle URL field (case-insensitive) + const urlValue = value.URL || value.url || value.Url; + cipher.login.uris = this.makeUriArray(urlValue); + + // Handle note field (case-insensitive) + const noteValue = value.note || value.Note || value.notes || value.Notes; + if (noteValue) { + cipher.notes = noteValue; + } + + // Process custom fields, excluding official props (case-insensitive) for (const prop in value) { // eslint-disable-next-line if (value.hasOwnProperty(prop)) { - if (!processingCustomFields && OfficialProps.indexOf(prop) === -1) { - processingCustomFields = true; - } - if (processingCustomFields) { + const lowerProp = prop.toLowerCase(); + const isOfficialProp = OfficialProps.some((p) => p.toLowerCase() === lowerProp); + if (!isOfficialProp && value[prop]) { this.processKvp(cipher, prop, value[prop]); } } diff --git a/libs/importer/src/importers/keepass2-xml-importer.spec.ts b/libs/importer/src/importers/keepass2-xml-importer.spec.ts index 8fbb021883c..c1c0947936b 100644 --- a/libs/importer/src/importers/keepass2-xml-importer.spec.ts +++ b/libs/importer/src/importers/keepass2-xml-importer.spec.ts @@ -1,3 +1,4 @@ +import { FieldType } from "@bitwarden/common/vault/enums"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { KeePass2XmlImporter } from "./keepass2-xml-importer"; @@ -5,6 +6,7 @@ import { TestData, TestData1, TestData2, + TestDataWithProtectedFields, } from "./spec-data/keepass2-xml/keepass2-xml-importer-testdata"; describe("KeePass2 Xml Importer", () => { @@ -43,4 +45,73 @@ describe("KeePass2 Xml Importer", () => { const result = await importer.parse(TestData2); expect(result.success).toBe(false); }); + + describe("protected fields handling", () => { + it("should import protected custom fields as hidden fields", async () => { + const importer = new KeePass2XmlImporter(); + const result = await importer.parse(TestDataWithProtectedFields); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + + const cipher = result.ciphers[0]; + expect(cipher.name).toBe("Test Entry"); + expect(cipher.login.username).toBe("testuser"); + expect(cipher.login.password).toBe("testpass"); + expect(cipher.notes).toContain("Regular notes"); + + // Check that protected custom field is imported as hidden field + const protectedField = cipher.fields.find((f) => f.name === "SAFE UN-LOCKING instructions"); + expect(protectedField).toBeDefined(); + expect(protectedField?.value).toBe("Secret instructions here"); + expect(protectedField?.type).toBe(FieldType.Hidden); + + // Check that regular custom field is imported as text field + const regularField = cipher.fields.find((f) => f.name === "CustomField"); + expect(regularField).toBeDefined(); + expect(regularField?.value).toBe("Custom value"); + expect(regularField?.type).toBe(FieldType.Text); + }); + + it("should import long protected fields as hidden fields (not appended to notes)", async () => { + const importer = new KeePass2XmlImporter(); + const result = await importer.parse(TestDataWithProtectedFields); + + const cipher = result.ciphers[0]; + + // Long protected field should be imported as hidden field + const longField = cipher.fields.find((f) => f.name === "LongProtectedField"); + expect(longField).toBeDefined(); + expect(longField?.type).toBe(FieldType.Hidden); + expect(longField?.value).toContain("This is a very long protected field"); + + // Should not be appended to notes + expect(cipher.notes).not.toContain("LongProtectedField"); + }); + + it("should import multiline protected fields as hidden fields (not appended to notes)", async () => { + const importer = new KeePass2XmlImporter(); + const result = await importer.parse(TestDataWithProtectedFields); + + const cipher = result.ciphers[0]; + + // Multiline protected field should be imported as hidden field + const multilineField = cipher.fields.find((f) => f.name === "MultilineProtectedField"); + expect(multilineField).toBeDefined(); + expect(multilineField?.type).toBe(FieldType.Hidden); + expect(multilineField?.value).toContain("Line 1"); + + // Should not be appended to notes + expect(cipher.notes).not.toContain("MultilineProtectedField"); + }); + + it("should not append protected custom fields to notes", async () => { + const importer = new KeePass2XmlImporter(); + const result = await importer.parse(TestDataWithProtectedFields); + + const cipher = result.ciphers[0]; + expect(cipher.notes).not.toContain("SAFE UN-LOCKING instructions"); + expect(cipher.notes).not.toContain("Secret instructions here"); + }); + }); }); diff --git a/libs/importer/src/importers/keepass2-xml-importer.ts b/libs/importer/src/importers/keepass2-xml-importer.ts index 0af7a6f829c..429ab2aa1b7 100644 --- a/libs/importer/src/importers/keepass2-xml-importer.ts +++ b/libs/importer/src/importers/keepass2-xml-importer.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { FieldType } from "@bitwarden/common/vault/enums"; +import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ImportResult } from "../models/import-result"; @@ -92,16 +93,26 @@ export class KeePass2XmlImporter extends BaseImporter implements Importer { } else if (key === "Notes") { cipher.notes += value + "\n"; } else { - let type = FieldType.Text; const attrs = valueEl.attributes as any; - if ( + const isProtected = attrs.length > 0 && attrs.ProtectInMemory != null && - attrs.ProtectInMemory.value === "True" - ) { - type = FieldType.Hidden; + attrs.ProtectInMemory.value === "True"; + + if (isProtected) { + // Protected fields should always be imported as hidden fields, + // regardless of length or newlines (fixes #16897) + if (cipher.fields == null) { + cipher.fields = []; + } + const field = new FieldView(); + field.type = FieldType.Hidden; + field.name = key; + field.value = value; + cipher.fields.push(field); + } else { + this.processKvp(cipher, key, value, FieldType.Text); } - this.processKvp(cipher, key, value, type); } }); diff --git a/libs/importer/src/importers/roboform-csv-importer.ts b/libs/importer/src/importers/roboform-csv-importer.ts index eb8a1ceac6a..6f557bb0db5 100644 --- a/libs/importer/src/importers/roboform-csv-importer.ts +++ b/libs/importer/src/importers/roboform-csv-importer.ts @@ -29,8 +29,9 @@ export class RoboFormCsvImporter extends BaseImporter implements Importer { cipher.notes = this.getValueOrDefault(value.Note); cipher.name = this.getValueOrDefault(value.Name, "--"); cipher.login.username = this.getValueOrDefault(value.Login); - cipher.login.password = this.getValueOrDefault(value.Pwd); - cipher.login.uris = this.makeUriArray(value.Url); + cipher.login.password = + this.getValueOrDefault(value.Pwd) ?? this.getValueOrDefault(value.Password); + cipher.login.uris = this.makeUriArray(value.Url) ?? this.makeUriArray(value.URL); if (!this.isNullOrWhitespace(value.Rf_fields)) { this.parseRfFields(cipher, value); diff --git a/libs/importer/src/importers/spec-data/buttercup-csv/testdata.csv.ts b/libs/importer/src/importers/spec-data/buttercup-csv/testdata.csv.ts new file mode 100644 index 00000000000..5e2f7a8d38c --- /dev/null +++ b/libs/importer/src/importers/spec-data/buttercup-csv/testdata.csv.ts @@ -0,0 +1,16 @@ +export const buttercupCsvTestData = `!group_id,!group_name,title,username,password,URL,id +1,General,Test Entry,testuser,testpass123,https://example.com,entry1 +1,General,Another Entry,anotheruser,anotherpass,https://another.com,entry2`; + +export const buttercupCsvWithUrlFieldTestData = `!group_id,!group_name,title,username,password,url,id +1,General,Entry With Lowercase URL,user1,pass1,https://lowercase-url.com,entry1`; + +export const buttercupCsvWithNoteTestData = `!group_id,!group_name,title,username,password,URL,note,id +1,General,Entry With Note,user1,pass1,https://example.com,This is a note,entry1`; + +export const buttercupCsvWithCustomFieldsTestData = `!group_id,!group_name,title,username,password,URL,custom_field,another_field,id +1,General,Entry With Custom Fields,user1,pass1,https://example.com,custom value,another value,entry1`; + +export const buttercupCsvWithSubfoldersTestData = `!group_id,!group_name,title,username,password,URL,id +1,Work/Projects,Project Entry,projectuser,projectpass,https://project.com,entry1 +2,Personal/Finance,Finance Entry,financeuser,financepass,https://finance.com,entry2`; diff --git a/libs/importer/src/importers/spec-data/keepass2-xml/keepass2-xml-importer-testdata.ts b/libs/importer/src/importers/spec-data/keepass2-xml/keepass2-xml-importer-testdata.ts index e06ca2cf655..9e1599b7078 100644 --- a/libs/importer/src/importers/spec-data/keepass2-xml/keepass2-xml-importer-testdata.ts +++ b/libs/importer/src/importers/spec-data/keepass2-xml/keepass2-xml-importer-testdata.ts @@ -354,6 +354,57 @@ line2 `; +export const TestDataWithProtectedFields = ` + + + + KvS57lVwl13AfGFLwkvq4Q== + Root + + fAa543oYlgnJKkhKag5HLw== + + Title + Test Entry + + + UserName + testuser + + + Password + testpass + + + URL + https://example.com + + + Notes + Regular notes + + + SAFE UN-LOCKING instructions + Secret instructions here + + + CustomField + Custom value + + + LongProtectedField + This is a very long protected field value that exceeds 200 characters. It contains sensitive information that should be imported as a hidden field and not appended to the notes section. This text is long enough to trigger the old behavior. + + + MultilineProtectedField + Line 1 +Line 2 +Line 3 + + + + +`; + export const TestData2 = ` KeePass diff --git a/libs/key-management-ui/src/index.ts b/libs/key-management-ui/src/index.ts index b273b49cb73..7b9d5a629ac 100644 --- a/libs/key-management-ui/src/index.ts +++ b/libs/key-management-ui/src/index.ts @@ -4,6 +4,8 @@ export { LockComponent } from "./lock/components/lock.component"; export { LockComponentService, UnlockOptions } from "./lock/services/lock-component.service"; +export { WebAuthnPrfUnlockService } from "./lock/services/webauthn-prf-unlock.service"; +export { DefaultWebAuthnPrfUnlockService } from "./lock/services/default-webauthn-prf-unlock.service"; export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust-info.component"; export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component"; export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component"; diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index c1577b76a4d..a93464b265c 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -49,6 +49,8 @@ + + @@ -113,6 +115,11 @@ + + @@ -127,6 +134,7 @@ [unlockOptions]="unlockOptions" [biometricUnlockBtnText]="biometricUnlockBtnText" (successfulUnlock)="successfulMasterPasswordUnlock($event)" + (prfUnlockSuccess)="onPrfUnlockSuccess($event)" (logOut)="logOut()" > } diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 054212f8851..47c4d14fc98 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -51,6 +51,7 @@ import { UnlockOptionValue, UnlockOptions, } from "../services/lock-component.service"; +import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service"; import { LockComponent } from "./lock.component"; @@ -84,6 +85,7 @@ describe("LockComponent", () => { const mockLockComponentService = mock(); const mockAnonLayoutWrapperDataService = mock(); const mockBroadcasterService = mock(); + const mockWebAuthnPrfUnlockService = mock(); const mockEncryptedMigrator = mock(); const mockActivatedRoute = { snapshot: { @@ -149,6 +151,7 @@ describe("LockComponent", () => { { provide: LockComponentService, useValue: mockLockComponentService }, { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, { provide: BroadcasterService, useValue: mockBroadcasterService }, + { provide: WebAuthnPrfUnlockService, useValue: mockWebAuthnPrfUnlockService }, { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: EncryptedMigrator, useValue: mockEncryptedMigrator }, ], diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 03ab6033441..9900aa6e827 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -6,7 +6,7 @@ import { BehaviorSubject, filter, firstValueFrom, - interval, + timer, mergeMap, Subject, switchMap, @@ -60,6 +60,7 @@ import { } from "../services/lock-component.service"; import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component"; +import { UnlockViaPrfComponent } from "./unlock-via-prf.component"; const BroadcasterSubscriptionId = "LockComponent"; @@ -98,6 +99,7 @@ const BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES = [ FormFieldModule, AsyncActionsModule, IconButtonModule, + UnlockViaPrfComponent, MasterPasswordLockComponent, TooltipDirective, ], @@ -197,7 +199,7 @@ export class LockComponent implements OnInit, OnDestroy { } private listenForUnlockOptionsChanges() { - interval(1000) + timer(0, 1000) .pipe( mergeMap(async () => { if (this.activeAccount?.id != null) { @@ -460,6 +462,14 @@ export class LockComponent implements OnInit, OnDestroy { } } + async onPrfUnlockSuccess(userKey: UserKey): Promise { + await this.setUserKeyAndContinue(userKey); + } + + togglePassword() { + this.showPassword = !this.showPassword; + } + private validatePin(): boolean { if (this.formGroup?.invalid) { this.toastService.showToast({ diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html index 4c7cdd48353..878915ec6ff 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html @@ -54,6 +54,11 @@ } + + diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts index dabab3e558a..6d0da1033b7 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts @@ -18,6 +18,7 @@ import { UserKey } from "@bitwarden/common/types/key"; import { AsyncActionsModule, ButtonModule, + DialogService, FormFieldModule, IconButtonModule, ToastService, @@ -27,6 +28,7 @@ import { CommandDefinition, MessageListener } from "@bitwarden/messaging"; import { UserId } from "@bitwarden/user-core"; import { UnlockOption, UnlockOptions } from "../../services/lock-component.service"; +import { WebAuthnPrfUnlockService } from "../../services/webauthn-prf-unlock.service"; import { MasterPasswordLockComponent } from "./master-password-lock.component"; @@ -41,6 +43,8 @@ describe("MasterPasswordLockComponent", () => { const logService = mock(); const platformUtilsService = mock(); const messageListener = mock(); + const webAuthnPrfUnlockService = mock(); + const dialogService = mock(); const mockMasterPassword = "testExample"; const activeAccount: Account = { @@ -64,6 +68,7 @@ describe("MasterPasswordLockComponent", () => { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledLocally, }, + prf: { enabled: false }, }; accountService.activeAccount$ = of(account); @@ -110,6 +115,8 @@ describe("MasterPasswordLockComponent", () => { { provide: LogService, useValue: logService }, { provide: PlatformUtilsService, useValue: platformUtilsService }, { provide: MessageListener, useValue: messageListener }, + { provide: WebAuthnPrfUnlockService, useValue: webAuthnPrfUnlockService }, + { provide: DialogService, useValue: dialogService }, ], }).compileComponents(); diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts index 1237869717f..5229effd366 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts @@ -36,6 +36,7 @@ import { UnlockOptions, UnlockOptionValue, } from "../../services/lock-component.service"; +import { UnlockViaPrfComponent } from "../unlock-via-prf.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -49,6 +50,7 @@ import { FormFieldModule, AsyncActionsModule, IconButtonModule, + UnlockViaPrfComponent, ], }) export class MasterPasswordLockComponent implements OnInit, OnDestroy { @@ -76,6 +78,7 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy { }); successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>(); + prfUnlockSuccess = output(); logOut = output(); protected showPassword = false; @@ -143,4 +146,8 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy { }); } } + + onPrfUnlockSuccess(userKey: UserKey): void { + this.prfUnlockSuccess.emit(userKey); + } } diff --git a/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts b/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts new file mode 100644 index 00000000000..7a0b99b232d --- /dev/null +++ b/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts @@ -0,0 +1,114 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit, input, output } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; +import { AsyncActionsModule, ButtonModule, DialogService } from "@bitwarden/components"; + +import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service"; + +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "bit-unlock-via-prf", + standalone: true, + imports: [CommonModule, JslibModule, ButtonModule, AsyncActionsModule], + template: ` + @if (isAvailable) { + @if (formButton()) { + + } + @if (!formButton()) { + + } + } + `, +}) +export class UnlockViaPrfComponent implements OnInit { + readonly formButton = input(false); + readonly unlockSuccess = output(); + + unlocking = false; + isAvailable = false; + private userId: UserId | null = null; + + constructor( + private accountService: AccountService, + private webAuthnPrfUnlockService: WebAuthnPrfUnlockService, + private dialogService: DialogService, + private i18nService: I18nService, + private logService: LogService, + ) {} + + async ngOnInit(): Promise { + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + if (activeAccount?.id) { + this.userId = activeAccount.id; + this.isAvailable = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(this.userId); + } + } + + async unlockViaPrf(): Promise { + if (!this.userId || !this.isAvailable) { + return; + } + + this.unlocking = true; + + try { + const userKey = await this.webAuthnPrfUnlockService.unlockVaultWithPrf(this.userId); + this.unlockSuccess.emit(userKey); + } catch (error) { + this.logService.error("[UnlockViaPrfComponent] Failed to unlock via PRF:", error); + + let errorMessage = this.i18nService.t("unexpectedError"); + + // Handle specific PRF error cases + if (error instanceof Error) { + if (error.message.includes("No PRF credentials")) { + errorMessage = this.i18nService.t("noPrfCredentialsAvailable"); + } else if (error.message.includes("canceled")) { + // User canceled the operation, don't show error + this.unlocking = false; + return; + } + } + + await this.dialogService.openSimpleDialog({ + title: { key: "error" }, + content: errorMessage, + acceptButtonText: { key: "ok" }, + type: "danger", + }); + } finally { + this.unlocking = false; + } + } +} diff --git a/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts b/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts new file mode 100644 index 00000000000..b3bbf392d0a --- /dev/null +++ b/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts @@ -0,0 +1,290 @@ +import { firstValueFrom } from "rxjs"; + +import { + UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, + WebAuthnPrfUserDecryptionOption, +} from "@bitwarden/auth/common"; +import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; +import { ClientType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils"; +import { UserId } from "@bitwarden/common/types/guid"; +import { PrfKey, UserKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { WebAuthnPrfUnlockService } from "./webauthn-prf-unlock.service"; + +export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService { + private navigatorCredentials: CredentialsContainer; + + constructor( + private webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction, + private keyService: KeyService, + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private encryptService: EncryptService, + private environmentService: EnvironmentService, + private platformUtilsService: PlatformUtilsService, + private window: Window, + private logService: LogService, + private configService: ConfigService, + ) { + this.navigatorCredentials = this.window.navigator.credentials; + } + + async isPrfUnlockAvailable(userId: UserId): Promise { + try { + // Check if feature flag is enabled + const passkeyUnlockEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PasskeyUnlock, + ); + if (!passkeyUnlockEnabled) { + return false; + } + + // Check if browser supports WebAuthn + if (!this.navigatorCredentials || !this.navigatorCredentials.get) { + return false; + } + + // PRF unlock is only supported on Web and Chromium-based browser extensions + const clientType = this.platformUtilsService.getClientType(); + if (clientType === ClientType.Browser && !this.platformUtilsService.isChromium()) { + return false; + } + if (clientType !== ClientType.Web && clientType !== ClientType.Browser) { + return false; + } + + // Check if user has any WebAuthn PRF credentials registered + const credentials = await this.getPrfUnlockCredentials(userId); + if (credentials.length === 0) { + return false; + } + + return true; + } catch (error) { + this.logService.error("Error checking PRF unlock availability:", error); + return false; + } + } + + private async getPrfUnlockCredentials( + userId: UserId, + ): Promise<{ credentialId: string; transports: string[] }[]> { + try { + const userDecryptionOptions = await this.getUserDecryptionOptions(userId); + if (!userDecryptionOptions?.webAuthnPrfOptions) { + return []; + } + return userDecryptionOptions.webAuthnPrfOptions.map((option) => ({ + credentialId: option.credentialId, + transports: option.transports, + })); + } catch (error) { + this.logService.error("Error getting PRF unlock credentials:", error); + return []; + } + } + + /** + * Unlocks the vault using WebAuthn PRF. + * + * @param userId The user ID to unlock vault for + * @returns Promise the decrypted user key + * @throws Error if unlock fails for any reason + */ + async unlockVaultWithPrf(userId: UserId): Promise { + // Get offline PRF credentials from user decryption options + const credentials = await this.getPrfUnlockCredentials(userId); + if (credentials.length === 0) { + throw new Error("No PRF credentials available for unlock"); + } + + const response = await this.performWebAuthnGetWithPrf(credentials, userId); + const prfKey = await this.createPrfKeyFromResponse(response); + const prfOption = await this.getPrfOptionForCredential(response.id, userId); + + // PRF unlock follows the same key derivation process as PRF login: + // PRF key → decrypt private key → use private key to decrypt user key + + // Step 1: Decrypt PRF encrypted private key using the PRF key + const privateKey = await this.encryptService.unwrapDecapsulationKey( + new EncString(prfOption.encryptedPrivateKey), + prfKey, + ); + + // Step 2: Use private key to decrypt user key + const userKey = await this.encryptService.decapsulateKeyUnsigned( + new EncString(prfOption.encryptedUserKey), + privateKey, + ); + + if (!userKey) { + throw new Error("Failed to decrypt user key from private key"); + } + + return userKey as UserKey; + } + + /** + * Performs WebAuthn get operation with PRF extension. + * + * @param credentials Available PRF credentials for the user + * @returns PublicKeyCredential response from the authenticator + * @throws Error if WebAuthn operation fails or returns invalid response + */ + private async performWebAuthnGetWithPrf( + credentials: { credentialId: string; transports: string[] }[], + userId: UserId, + ): Promise { + const rpId = await this.getRpIdForUser(userId); + const prfSalt = await this.getUnlockWithPrfSalt(); + + const options: CredentialRequestOptions = { + publicKey: { + challenge: new Uint8Array(32), + allowCredentials: credentials.map(({ credentialId, transports }) => { + // The credential ID is already base64url encoded from login storage + // We need to decode it to ArrayBuffer for WebAuthn + const decodedId = Fido2Utils.stringToBuffer(credentialId); + return { + type: "public-key", + id: decodedId, + transports: (transports || []) as AuthenticatorTransport[], + }; + }), + rpId, + userVerification: "preferred", // Allow platform authenticators to work properly + extensions: { + prf: { eval: { first: prfSalt } }, + } as any, + }, + }; + + const response = await this.navigatorCredentials.get(options); + + if (!response) { + throw new Error("WebAuthn get() returned null/undefined"); + } + + if (!(response instanceof PublicKeyCredential)) { + throw new Error("Failed to get PRF credential for unlock"); + } + + return response; + } + + /** + * Extracts PRF result from WebAuthn response and creates a PrfKey. + * + * @param response PublicKeyCredential response from authenticator + * @returns PrfKey derived from the PRF extension output + * @throws Error if no PRF result is present in the response + */ + private async createPrfKeyFromResponse(response: PublicKeyCredential): Promise { + // Extract PRF result + // TODO: Remove `any` when typescript typings add support for PRF + const extensionResults = response.getClientExtensionResults() as any; + const prfResult = extensionResults.prf?.results?.first; + if (!prfResult) { + throw new Error("No PRF result received from authenticator"); + } + + try { + return await this.webAuthnLoginPrfKeyService.createSymmetricKeyFromPrf(prfResult); + } catch (error) { + this.logService.error("Failed to create unlock key from PRF:", error); + throw error; + } + } + + /** + * Gets the WebAuthn PRF option that matches the credential used in the response. + * + * @param credentialId Credential ID to match + * @param userId User ID to get decryption options for + * @returns Matching WebAuthnPrfUserDecryptionOption with encrypted keys + * @throws Error if no PRF options exist or no matching option is found + */ + private async getPrfOptionForCredential( + credentialId: string, + userId: UserId, + ): Promise { + const userDecryptionOptions = await this.getUserDecryptionOptions(userId); + + if ( + !userDecryptionOptions?.webAuthnPrfOptions || + userDecryptionOptions.webAuthnPrfOptions.length === 0 + ) { + throw new Error("No WebAuthn PRF option found for user - cannot perform PRF unlock"); + } + + const prfOption = userDecryptionOptions.webAuthnPrfOptions.find( + (option) => option.credentialId === credentialId, + ); + + if (!prfOption) { + throw new Error("No matching WebAuthn PRF option found for this credential"); + } + + return prfOption; + } + + private async getUnlockWithPrfSalt(): Promise { + try { + // Use the same salt as login to ensure PRF keys match + return await this.webAuthnLoginPrfKeyService.getLoginWithPrfSalt(); + } catch (error) { + this.logService.error("Error getting unlock PRF salt:", error); + throw error; + } + } + + /** + * Helper method to get user decryption options for a user + */ + private async getUserDecryptionOptions(userId: UserId): Promise { + try { + return (await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + )) as UserDecryptionOptions; + } catch (error) { + this.logService.error("Error getting user decryption options:", error); + return null; + } + } + + /** + * Helper method to get the appropriate rpId for WebAuthn PRF operations + * Returns the hostname from the user's environment configuration + */ + private async getRpIdForUser(userId: UserId): Promise { + try { + const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId)); + const hostname = Utils.getHost(environment.getWebVaultUrl()); + + // The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host. + if (!hostname) { + return undefined; + } + + // Extract hostname using URL parsing to handle IPv6 and ports correctly + // This removes ports etc. + const url = new URL(`https://${hostname}`); + const rpId = url.hostname; + + return rpId; + } catch (error) { + this.logService.error("Error getting rpId", error); + return undefined; + } + } +} diff --git a/libs/key-management-ui/src/lock/services/lock-component.service.ts b/libs/key-management-ui/src/lock/services/lock-component.service.ts index 0fc25ca7dfb..53cb256f251 100644 --- a/libs/key-management-ui/src/lock/services/lock-component.service.ts +++ b/libs/key-management-ui/src/lock/services/lock-component.service.ts @@ -10,6 +10,7 @@ export const UnlockOption = Object.freeze({ MasterPassword: "masterPassword", Pin: "pin", Biometrics: "biometrics", + Prf: "prf", }) satisfies { [Prop in keyof UnlockOptions as Capitalize]: Prop }; export type UnlockOptions = { @@ -23,6 +24,9 @@ export type UnlockOptions = { enabled: boolean; biometricsStatus: BiometricsStatus; }; + prf: { + enabled: boolean; + }; }; /** diff --git a/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts b/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts new file mode 100644 index 00000000000..f0b02a0ed3f --- /dev/null +++ b/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts @@ -0,0 +1,27 @@ +import { UserKey } from "@bitwarden/common/types/key"; +import { UserId } from "@bitwarden/user-core"; + +/** + * Service for unlocking vault using WebAuthn PRF. + * Provides offline vault unlock capabilities by deriving unlock keys from PRF outputs. + */ +export abstract class WebAuthnPrfUnlockService { + /** + * Check if PRF unlock is available for the current user + * @param userId The user ID to check PRF unlock availability for + * @returns Promise true if PRF unlock is available + */ + abstract isPrfUnlockAvailable(userId: UserId): Promise; + + /** + * Attempt to unlock the vault using WebAuthn PRF + * @param userId The user ID to unlock vault for + * @returns Promise the decrypted user key + * @throws Error if no PRF credentials are available + * @throws Error if the authenticator returns no PRF result + * @throws Error if the user cancels the WebAuthn operation + * @throws Error if decryption of the user key fails + * @throws Error if no matching PRF option is found for the credential + */ + abstract unlockVaultWithPrf(userId: UserId): Promise; +} diff --git a/libs/key-management/src/biometrics/biometric-state.service.spec.ts b/libs/key-management/src/biometrics/biometric-state.service.spec.ts index 32043514ff7..2f1f189a897 100644 --- a/libs/key-management/src/biometrics/biometric-state.service.spec.ts +++ b/libs/key-management/src/biometrics/biometric-state.service.spec.ts @@ -179,18 +179,36 @@ describe("BiometricStateService", () => { }); describe("biometricUnlockEnabled$", () => { - it("emits when biometricUnlockEnabled state is updated", async () => { - const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); - state.nextState(true); + describe("no user id provided, active user", () => { + it("emits when biometricUnlockEnabled state is updated", async () => { + const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); + state.nextState(true); - expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true); + expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(true); + }); + + it("emits false when biometricUnlockEnabled state is undefined", async () => { + const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); + state.nextState(undefined as unknown as boolean); + + expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(false); + }); }); - it("emits false when biometricUnlockEnabled state is undefined", async () => { - const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); - state.nextState(undefined as unknown as boolean); + describe("user id provided", () => { + it("returns biometricUnlockEnabled state for the given user", async () => { + stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true); - expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(false); + expect(await firstValueFrom(sut.biometricUnlockEnabled$(userId))).toBe(true); + }); + + it("returns false when the state is not set", async () => { + stateProvider.singleUser + .getFake(userId, BIOMETRIC_UNLOCK_ENABLED) + .nextState(undefined as unknown as boolean); + + expect(await firstValueFrom(sut.biometricUnlockEnabled$(userId))).toBe(false); + }); }); }); @@ -198,7 +216,7 @@ describe("BiometricStateService", () => { it("updates biometricUnlockEnabled$", async () => { await sut.setBiometricUnlockEnabled(true); - expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true); + expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(true); }); it("updates state", async () => { @@ -210,22 +228,6 @@ describe("BiometricStateService", () => { }); }); - describe("getBiometricUnlockEnabled", () => { - it("returns biometricUnlockEnabled state for the given user", async () => { - stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true); - - expect(await sut.getBiometricUnlockEnabled(userId)).toBe(true); - }); - - it("returns false when the state is not set", async () => { - stateProvider.singleUser - .getFake(userId, BIOMETRIC_UNLOCK_ENABLED) - .nextState(undefined as unknown as boolean); - - expect(await sut.getBiometricUnlockEnabled(userId)).toBe(false); - }); - }); - describe("setFingerprintValidated", () => { it("updates fingerprintValidated$", async () => { await sut.setFingerprintValidated(true); diff --git a/libs/key-management/src/biometrics/biometric-state.service.ts b/libs/key-management/src/biometrics/biometric-state.service.ts index 1488f12b50b..ca1cbcfa871 100644 --- a/libs/key-management/src/biometrics/biometric-state.service.ts +++ b/libs/key-management/src/biometrics/biometric-state.service.ts @@ -18,9 +18,11 @@ import { export abstract class BiometricStateService { /** - * `true` if the currently active user has elected to store a biometric key to unlock their vault. + * Returns whether biometric unlock is enabled for a user. + * @param userId The user id to check. If not provided, returns the state for the currently active user. + * @returns An observable that emits `true` if the user has elected to store a biometric key to unlock their vault. */ - abstract biometricUnlockEnabled$: Observable; // used to be biometricUnlock + abstract biometricUnlockEnabled$(userId?: UserId): Observable; /** * If the user has elected to require a password on first unlock of an application instance, this key will store the * encrypted client key half used to unlock the vault. @@ -53,6 +55,7 @@ export abstract class BiometricStateService { /** * Gets the biometric unlock enabled state for the given user. + * @deprecated Use {@link biometricUnlockEnabled$} instead * @param userId user Id to check */ abstract getBiometricUnlockEnabled(userId: UserId): Promise; @@ -103,7 +106,6 @@ export class DefaultBiometricStateService implements BiometricStateService { private promptAutomaticallyState: ActiveUserState; private fingerprintValidatedState: GlobalState; private lastProcessReloadState: GlobalState; - biometricUnlockEnabled$: Observable; encryptedClientKeyHalf$: Observable; promptCancelled$: Observable; promptAutomatically$: Observable; @@ -112,7 +114,6 @@ export class DefaultBiometricStateService implements BiometricStateService { constructor(private stateProvider: StateProvider) { this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED); - this.biometricUnlockEnabled$ = this.biometricUnlockEnabledState.state$.pipe(map(Boolean)); this.encryptedClientKeyHalfState = this.stateProvider.getActive(ENCRYPTED_CLIENT_KEY_HALF); this.encryptedClientKeyHalf$ = this.encryptedClientKeyHalfState.state$.pipe( @@ -142,6 +143,15 @@ export class DefaultBiometricStateService implements BiometricStateService { await this.biometricUnlockEnabledState.update(() => enabled); } + biometricUnlockEnabled$(userId?: UserId): Observable { + if (userId != null) { + return this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)); + } + // Backwards compatibility for active user state + // TODO remove with https://bitwarden.atlassian.net/browse/PM-12043 + return this.biometricUnlockEnabledState.state$.pipe(map(Boolean)); + } + async getBiometricUnlockEnabled(userId: UserId): Promise { return await firstValueFrom( this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)), 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 e916de3995d..d3a0ad25e6c 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -16,7 +16,7 @@ {{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD

  - / {{ term }} + / {{ term }} }
- } -
+
+ }
@@ -67,10 +69,12 @@
    @for (feature of featureList; track feature) {
  • - + > + {{ feature }} diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.mdx b/libs/pricing/src/components/pricing-card/pricing-card.component.mdx index 905b8e6981f..1cbac94d8ee 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.mdx +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.mdx @@ -39,7 +39,7 @@ import { PricingCardComponent } from "@bitwarden/pricing"; | Input | Type | Description | | ------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tagline` | `string` | **Required.** Descriptive text below title (max 2 lines) | -| `price` | `{ amount: number; cadence: "monthly" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown | +| `price` | `{ amount: number; cadence: "month" \| "monthly" \| "year" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown | | `button` | `{ type: ButtonType; text: string; disabled?: boolean; icon?: { type: string; position: "before" \| "after" } }` | **Optional.** Button configuration with optional icon. If omitted, no button is shown. Icon uses `bwi-*` classes, position defaults to "after" | | `features` | `string[]` | **Optional.** List of features with checkmarks | | `activeBadge` | `{ text: string; variant?: BadgeVariant }` | **Optional.** Active plan badge using proper Badge component, positioned on the same line as title, aligned to the right. If omitted, no badge is shown | @@ -182,6 +182,58 @@ For coming soon or unavailable plans: ``` +### With Button Icons + +Add icons to buttons for enhanced visual communication: + + + +```html + + + + + + + +``` + +### Active Plan Badge + +Show which plan is currently active: + + + +```html + + +``` + ### Pricing Grid Layout Multiple cards displayed together: diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts index 735d694152c..fc8a9541952 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts @@ -2,7 +2,8 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { BadgeVariant, ButtonType, IconModule, TypographyModule } from "@bitwarden/components"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { BadgeVariant, ButtonType, SvgModule, TypographyModule } from "@bitwarden/components"; import { PricingCardComponent } from "@bitwarden/pricing"; @Component({ @@ -68,12 +69,29 @@ describe("PricingCardComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - PricingCardComponent, - TestHostComponent, - IconModule, - TypographyModule, - CommonModule, + imports: [PricingCardComponent, TestHostComponent, SvgModule, TypographyModule, CommonModule], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string) => { + switch (key) { + case "month": + return "month"; + case "monthly": + return "monthly"; + case "year": + return "year"; + case "annually": + return "annually"; + case "perUser": + return "per user"; + default: + return key; + } + }, + }, + }, ], }).compileComponents(); @@ -157,7 +175,7 @@ describe("PricingCardComponent", () => { it("should display bwi-check icons for features", () => { hostFixture.detectChanges(); const compiled = hostFixture.nativeElement; - const icons = compiled.querySelectorAll("i.bwi-check"); + const icons = compiled.querySelectorAll("bit-icon[name='bwi-check']"); expect(icons.length).toBe(3); // One for each feature }); diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts index 832345de357..63946cbf19a 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts @@ -1,15 +1,42 @@ -import { Meta, StoryObj } from "@storybook/angular"; +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; -import { TypographyModule } from "@bitwarden/components"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SvgModule, TypographyModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { PricingCardComponent } from "./pricing-card.component"; export default { title: "Billing/Pricing Card", component: PricingCardComponent, - moduleMetadata: { - imports: [TypographyModule], - }, + decorators: [ + moduleMetadata({ + imports: [PricingCardComponent, SvgModule, TypographyModule, I18nPipe], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string) => { + switch (key) { + case "month": + return "month"; + case "monthly": + return "monthly"; + case "year": + return "year"; + case "annually": + return "annually"; + case "perUser": + return "per user"; + default: + return key; + } + }, + }, + }, + ], + }), + ], args: { tagline: "Everything you need for secure password management across all your devices", }, @@ -83,7 +110,7 @@ export const WithoutFeatures: Story = { }), args: { tagline: "Advanced security and management for your organization", - price: { amount: 3, cadence: "monthly" }, + price: { amount: 3, cadence: "month" }, button: { text: "Contact Sales", type: "primary" }, }, }; @@ -150,7 +177,7 @@ export const LongTagline: Story = { args: { tagline: "Comprehensive password management solution for teams and organizations that need advanced security features, detailed reporting, and enterprise-grade administration tools that scale with your business", - price: { amount: 5, cadence: "monthly", showPerUser: true }, + price: { amount: 5, cadence: "month", showPerUser: true }, button: { text: "Start Business Trial", type: "primary" }, features: [ "Everything in Premium", @@ -274,7 +301,7 @@ export const WithoutButton: Story = { }), args: { tagline: "This plan will be available soon with exciting new features", - price: { amount: 15, cadence: "monthly" }, + price: { amount: 15, cadence: "month" }, features: ["Advanced security features", "Enhanced collaboration tools", "Premium support"], }, }; diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.ts index c9da7c32462..23eda0fa99b 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.ts @@ -4,12 +4,15 @@ import { ChangeDetectionStrategy, Component, input, output } from "@angular/core import { BadgeModule, BadgeVariant, + BitwardenIcon, ButtonModule, ButtonType, CardComponent, IconModule, + SvgModule, TypographyModule, } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; /** * A reusable UI-only component that displays pricing information in a card format. @@ -20,20 +23,29 @@ import { selector: "billing-pricing-card", templateUrl: "./pricing-card.component.html", changeDetection: ChangeDetectionStrategy.OnPush, - imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe, CardComponent], + imports: [ + BadgeModule, + ButtonModule, + SvgModule, + IconModule, + TypographyModule, + CurrencyPipe, + CardComponent, + I18nPipe, + ], }) export class PricingCardComponent { readonly tagline = input.required(); readonly price = input<{ amount: number; - cadence: "monthly" | "annually"; + cadence: "month" | "monthly" | "year" | "annually"; showPerUser?: boolean; }>(); readonly button = input<{ type: ButtonType; text: string; disabled?: boolean; - icon?: { type: string; position: "before" | "after" }; + icon?: { type: BitwardenIcon; position: "before" | "after" }; }>(); readonly features = input(); readonly activeBadge = input<{ text: string; variant?: BadgeVariant }>(); diff --git a/libs/pricing/src/types/cart.ts b/libs/pricing/src/types/cart.ts index ed5108edee8..aeec6b269af 100644 --- a/libs/pricing/src/types/cart.ts +++ b/libs/pricing/src/types/cart.ts @@ -1,10 +1,14 @@ import { Discount } from "@bitwarden/pricing"; +import { Credit } from "./credit"; + export type CartItem = { translationKey: string; + translationParams?: Array; quantity: number; cost: number; discount?: Discount; + hideBreakdown?: boolean; }; export type Cart = { @@ -18,5 +22,6 @@ export type Cart = { }; cadence: "annually" | "monthly"; discount?: Discount; + credit?: Credit; estimatedTax: number; }; diff --git a/libs/pricing/src/types/credit.ts b/libs/pricing/src/types/credit.ts new file mode 100644 index 00000000000..bb7e42bcb62 --- /dev/null +++ b/libs/pricing/src/types/credit.ts @@ -0,0 +1,5 @@ +export type Credit = { + translationKey: string; + translationParams?: Array; + value: number; +}; diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts index 620f465789c..7adf7b4138f 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts @@ -59,6 +59,7 @@ export class BaseVaultExportService { cipher.notes = c.notes; cipher.fields = null; cipher.reprompt = c.reprompt; + cipher.archivedDate = c.archivedDate ? c.archivedDate.toISOString() : null; // Login props cipher.login_uri = null; cipher.login_username = null; diff --git a/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-csv-export-type.ts b/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-csv-export-type.ts index 30c6bb89bc1..efe15a844fc 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-csv-export-type.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-csv-export-type.ts @@ -12,6 +12,7 @@ export type BitwardenCsvExportType = { login_password: string; login_totp: string; favorite: number | null; + archivedDate: string | null; }; export type BitwardenCsvIndividualExportType = BitwardenCsvExportType & { diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html index a271788b0ef..3f28ed289c9 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html @@ -7,64 +7,22 @@ {{ "limitSendViews" | i18n }} {{ "limitSendViewsHint" | i18n }} -  {{ "limitSendViewsCount" | i18n: viewsLeft }} + @if (shouldShowCount) { +  {{ "limitSendViewsCount" | i18n: viewsLeft }} + } - - {{ (passwordRemoved ? "newPassword" : "password") | i18n }} - - - - - - {{ "sendPasswordDescV3" | i18n }} - - - - {{ "hideYourEmail" | i18n }} - + + @if (!disableHideEmail || originalSendView?.hideEmail) { + + + {{ "hideYourEmail" | i18n }} + + } {{ "privateNote" | i18n }} 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 fa069b92ed2..47e8403f770 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 @@ -5,12 +5,7 @@ import { of } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; 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 { 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"; import { SendFormContainer } from "../../send-form-container"; @@ -32,14 +27,9 @@ describe("SendOptionsComponent", () => { declarations: [], providers: [ { provide: SendFormContainer, useValue: mockSendFormContainer }, - { provide: DialogService, useValue: mock() }, - { provide: SendApiService, useValue: mock() }, { provide: PolicyService, useValue: mock() }, { provide: I18nService, useValue: mock() }, - { provide: ToastService, useValue: mock() }, - { provide: CredentialGeneratorService, useValue: mock() }, { provide: AccountService, useValue: mockAccountService }, - { provide: PlatformUtilsService, useValue: mock() }, ], }).compileComponents(); fixture = TestBed.createComponent(SendOptionsComponent); @@ -55,13 +45,4 @@ describe("SendOptionsComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); - - it("should emit a null password when password textbox is empty", async () => { - const newSend = {} as SendView; - mockSendFormContainer.patchSend.mockImplementation((updateFn) => updateFn(newSend)); - component.sendOptionsForm.patchValue({ password: "testing" }); - expect(newSend.password).toBe("testing"); - component.sendOptionsForm.patchValue({ password: "" }); - expect(newSend.password).toBe(null); - }); }); diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts index ae8706a375e..a5f369d66aa 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts @@ -4,32 +4,26 @@ import { CommonModule } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { BehaviorSubject, firstValueFrom, map, switchMap, tap } from "rxjs"; +import { switchMap, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { pin } from "@bitwarden/common/tools/rx"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { + TypographyModule, AsyncActionsModule, ButtonModule, CardComponent, CheckboxModule, - DialogService, FormFieldModule, IconButtonModule, SectionComponent, SectionHeaderComponent, - ToastService, - TypographyModule, + SelectModule, } from "@bitwarden/components"; -import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; @@ -39,6 +33,7 @@ import { SendFormContainer } from "../../send-form-container"; @Component({ selector: "tools-send-options", templateUrl: "./send-options.component.html", + standalone: true, imports: [ AsyncActionsModule, ButtonModule, @@ -51,6 +46,7 @@ import { SendFormContainer } from "../../send-form-container"; ReactiveFormsModule, SectionComponent, SectionHeaderComponent, + SelectModule, TypographyModule, ], }) @@ -64,19 +60,14 @@ export class SendOptionsComponent implements OnInit { @Input() originalSendView: SendView; disableHideEmail = false; - passwordRemoved = false; + sendOptionsForm = this.formBuilder.group({ maxAccessCount: [null as number], accessCount: [null as number], notes: [null as string], - password: [null as string], hideEmail: [false as boolean], }); - get hasPassword(): boolean { - return this.originalSendView && this.originalSendView.password !== null; - } - get shouldShowCount(): boolean { return this.config.mode === "edit" && this.sendOptionsForm.value.maxAccessCount !== null; } @@ -91,13 +82,8 @@ export class SendOptionsComponent implements OnInit { constructor( private sendFormContainer: SendFormContainer, - private dialogService: DialogService, - private sendApiService: SendApiService, private formBuilder: FormBuilder, private policyService: PolicyService, - private i18nService: I18nService, - private toastService: ToastService, - private generatorService: CredentialGeneratorService, private accountService: AccountService, ) { this.sendFormContainer.registerChildForm("sendOptionsForm", this.sendOptionsForm); @@ -113,87 +99,28 @@ export class SendOptionsComponent implements OnInit { this.disableHideEmail = disableHideEmail; }); - this.sendOptionsForm.valueChanges - .pipe( - tap((value) => { - if (Utils.isNullOrWhitespace(value.password)) { - value.password = null; - } - }), - takeUntilDestroyed(), - ) - .subscribe((value) => { - this.sendFormContainer.patchSend((send) => { - Object.assign(send, { - maxAccessCount: value.maxAccessCount, - accessCount: value.accessCount, - password: value.password, - hideEmail: value.hideEmail, - notes: value.notes, - }); - return send; + this.sendOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { + this.sendFormContainer.patchSend((send) => { + Object.assign(send, { + maxAccessCount: value.maxAccessCount, + accessCount: value.accessCount, + hideEmail: value.hideEmail, + notes: value.notes, }); + return send; }); + }); } - generatePassword = async () => { - const on$ = new BehaviorSubject({ source: "send", type: Type.password }); - const account$ = this.accountService.activeAccount$.pipe( - pin({ name: () => "send-options.component", distinct: (p, c) => p.id === c.id }), - ); - const generatedCredential = await firstValueFrom( - this.generatorService.generate$({ on$, account$ }), - ); - - this.sendOptionsForm.patchValue({ - password: generatedCredential.credential, - }); - }; - - removePassword = async () => { - if (!this.originalSendView || !this.originalSendView.password) { - return; - } - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "removePassword" }, - content: { key: "removePasswordConfirmation" }, - type: "warning", - }); - - if (!confirmed) { - return false; - } - - this.passwordRemoved = true; - - await this.sendApiService.removePassword(this.originalSendView.id); - - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("removedPassword"), - }); - - this.originalSendView.password = null; - this.sendOptionsForm.patchValue({ - password: null, - }); - this.sendOptionsForm.get("password")?.enable(); - }; - ngOnInit() { if (this.sendFormContainer.originalSendView) { this.sendOptionsForm.patchValue({ maxAccessCount: this.sendFormContainer.originalSendView.maxAccessCount, accessCount: this.sendFormContainer.originalSendView.accessCount, - password: this.hasPassword ? "************" : null, // 12 masked characters as a placeholder hideEmail: this.sendFormContainer.originalSendView.hideEmail, notes: this.sendFormContainer.originalSendView.notes, }); } - if (this.hasPassword) { - this.sendOptionsForm.get("password")?.disable(); - } if (!this.config.areSendsAllowed) { this.sendOptionsForm.disable(); diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index e650ca3a5df..581ee20caf7 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -6,7 +6,7 @@ {{ "name" | i18n }} - + - + {{ "deletionDate" | i18n }} {{ "deletionDateDescV2" | i18n }} + + + {{ "whoCanView" | i18n }} + + @for (option of availableAuthTypes$ | async; track option.value) { + + } + + @if (sendDetailsForm.get("authType").value === AuthType.Email) { + {{ "emailVerificationDesc" | i18n }} + } + + + @if (sendDetailsForm.get("authType").value === AuthType.Password) { + + {{ (passwordRemoved ? "newPassword" : "password") | i18n }} + +
    + @if (!hasPassword) { + + + + } @else { + + } +
    + {{ "sendPasswordDescV3" | i18n }} +
    + } + + @if (sendDetailsForm.get("authType").value === AuthType.Email) { + + {{ "emails" | i18n }} + + {{ "enterMultipleEmailsSeparatedByComma" | i18n }} + + } diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts index 576842cd877..f816c9d5ce4 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts @@ -1,4 +1,29 @@ -import { DatePreset, isDatePreset, asDatePreset } from "./send-details.component"; +import { DatePipe } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +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 { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { CredentialGeneratorService } from "@bitwarden/generator-core"; + +import { SendFormContainer } from "../../send-form-container"; + +import { + DatePreset, + SendDetailsComponent, + asDatePreset, + isDatePreset, +} from "./send-details.component"; describe("SendDetails DatePreset utilities", () => { it("accepts all defined numeric presets", () => { @@ -25,3 +50,81 @@ describe("SendDetails DatePreset utilities", () => { }); }); }); + +describe("SendDetailsComponent", () => { + let component: SendDetailsComponent; + let fixture: ComponentFixture; + const mockSendFormContainer = mock(); + const mockI18nService = mock(); + const mockConfigService = mock(); + const mockAccountService = mock(); + const mockBillingStateService = mock(); + const mockGeneratorService = mock(); + const mockSendApiService = mock(); + const mockEnvironmentService = mock(); + + beforeEach(async () => { + mockEnvironmentService.environment$ = of({ + getSendUrl: () => "https://send.bitwarden.com/", + } as any); + mockAccountService.activeAccount$ = of({ id: "userId" } as Account); + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + mockI18nService.t.mockImplementation((k) => k); + + await TestBed.configureTestingModule({ + imports: [SendDetailsComponent, ReactiveFormsModule], + providers: [ + { provide: SendFormContainer, useValue: mockSendFormContainer }, + { provide: I18nService, useValue: mockI18nService }, + { provide: DatePipe, useValue: new DatePipe("en-US") }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: BillingAccountProfileStateService, useValue: mockBillingStateService }, + { provide: CredentialGeneratorService, useValue: mockGeneratorService }, + { provide: SendApiService, useValue: mockSendApiService }, + { provide: PolicyService, useValue: mock() }, + { provide: DialogService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SendDetailsComponent); + component = fixture.componentInstance; + component.config = { areSendsAllowed: true, mode: "add", sendType: SendType.Text }; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize authType to None if no password or emails", () => { + expect(component.sendDetailsForm.value.authType).toBe(AuthType.None); + }); + + it("should toggle validation based on authType", () => { + const emailsControl = component.sendDetailsForm.get("emails"); + const passwordControl = component.sendDetailsForm.get("password"); + + // Default + expect(emailsControl?.validator).toBeNull(); + expect(passwordControl?.validator).toBeNull(); + + // Select Email + component.sendDetailsForm.patchValue({ authType: AuthType.Email }); + expect(emailsControl?.validator).not.toBeNull(); + expect(passwordControl?.validator).toBeNull(); + + // Select Password + component.sendDetailsForm.patchValue({ authType: AuthType.Password }); + expect(passwordControl?.validator).not.toBeNull(); + expect(emailsControl?.validator).toBeNull(); + + // Select None + component.sendDetailsForm.patchValue({ authType: AuthType.None }); + expect(emailsControl?.validator).toBeNull(); + expect(passwordControl?.validator).toBeNull(); + }); +}); 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 e2b50eafc99..46eded5e86d 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 @@ -3,13 +3,28 @@ import { CommonModule, DatePipe } from "@angular/common"; import { Component, OnInit, Input } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; -import { firstValueFrom } from "rxjs"; +import { + FormBuilder, + FormControl, + ReactiveFormsModule, + Validators, + ValidatorFn, + ValidationErrors, +} from "@angular/forms"; +import { firstValueFrom, BehaviorSubject, combineLatest, map, switchMap, tap } 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/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 { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { pin } from "@bitwarden/common/tools/rx"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SectionComponent, @@ -20,7 +35,12 @@ import { IconButtonModule, CheckboxModule, SelectModule, + AsyncActionsModule, + ButtonModule, + ToastService, + DialogService, } from "@bitwarden/components"; +import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; @@ -78,6 +98,7 @@ export function asDatePreset(value: unknown): DatePreset | undefined { @Component({ selector: "tools-send-details", templateUrl: "./send-details.component.html", + standalone: true, imports: [ SectionComponent, SectionHeaderComponent, @@ -92,7 +113,10 @@ export function asDatePreset(value: unknown): DatePreset | undefined { IconButtonModule, CheckboxModule, CommonModule, + CommonModule, SelectModule, + AsyncActionsModule, + ButtonModule, ], }) export class SendDetailsComponent implements OnInit { @@ -105,31 +129,110 @@ export class SendDetailsComponent implements OnInit { FileSendType = SendType.File; TextSendType = SendType.Text; + readonly AuthType = AuthType; sendLink: string | null = null; customDeletionDateOption: DatePresetSelectOption | null = null; datePresetOptions: DatePresetSelectOption[] = []; + passwordRemoved = false; + + emailVerificationFeatureFlag$ = this.configService.getFeatureFlag$(FeatureFlag.SendEmailOTP); + hasPremium$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ), + ); + + authTypes: { name: string; value: AuthType; disabled?: boolean }[] = [ + { name: this.i18nService.t("noAuth"), value: AuthType.None }, + { name: this.i18nService.t("specificPeople"), value: AuthType.Email }, + { name: this.i18nService.t("anyOneWithPassword"), value: AuthType.Password }, + ]; + + availableAuthTypes$ = combineLatest([this.emailVerificationFeatureFlag$, this.hasPremium$]).pipe( + map(([enabled, hasPremium]) => { + if (!enabled || !hasPremium) { + return this.authTypes.filter((t) => t.value !== AuthType.Email); + } + return this.authTypes; + }), + ); sendDetailsForm = this.formBuilder.group({ name: new FormControl("", Validators.required), selectedDeletionDatePreset: new FormControl(DatePreset.SevenDays || "", Validators.required), + authType: [AuthType.None as AuthType], + password: [null as string], + emails: [null as string], }); + get hasPassword(): boolean { + return this.originalSendView?.password != null; + } + constructor( protected sendFormContainer: SendFormContainer, protected formBuilder: FormBuilder, protected i18nService: I18nService, protected datePipe: DatePipe, protected environmentService: EnvironmentService, + private configService: ConfigService, + private accountService: AccountService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private generatorService: CredentialGeneratorService, + private sendApiService: SendApiService, + private dialogService: DialogService, + private toastService: ToastService, ) { - this.sendDetailsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { - this.sendFormContainer.patchSend((send) => { - return Object.assign(send, { - name: value.name, - deletionDate: new Date(this.formattedDeletionDate), - expirationDate: new Date(this.formattedDeletionDate), - } as SendView); + this.sendDetailsForm.valueChanges + .pipe( + tap((value) => { + if (Utils.isNullOrWhitespace(value.password)) { + value.password = null; + } + }), + takeUntilDestroyed(), + ) + .subscribe((value) => { + this.sendFormContainer.patchSend((send) => { + return Object.assign(send, { + name: value.name, + deletionDate: new Date(this.formattedDeletionDate), + expirationDate: new Date(this.formattedDeletionDate), + password: value.password, + emails: value.emails + ? value.emails + .split(",") + .map((e) => e.trim()) + .filter((e) => e.length > 0) + : null, + } as unknown as SendView); + }); + }); + + this.sendDetailsForm + .get("authType") + .valueChanges.pipe(takeUntilDestroyed()) + .subscribe((type) => { + const emailsControl = this.sendDetailsForm.get("emails"); + const passwordControl = this.sendDetailsForm.get("password"); + + if (type === AuthType.Password) { + emailsControl.setValue(null); + emailsControl.clearValidators(); + passwordControl.setValidators([Validators.required]); + } else if (type === AuthType.Email) { + passwordControl.setValue(null); + passwordControl.clearValidators(); + emailsControl.setValidators([Validators.required, this.emailListValidator()]); + } else { + emailsControl.setValue(null); + emailsControl.clearValidators(); + passwordControl.setValue(null); + passwordControl.clearValidators(); + } + emailsControl.updateValueAndValidity(); + passwordControl.updateValueAndValidity(); }); - }); this.sendFormContainer.registerChildForm("sendDetailsForm", this.sendDetailsForm); } @@ -141,8 +244,15 @@ export class SendDetailsComponent implements OnInit { this.sendDetailsForm.patchValue({ name: this.originalSendView.name, selectedDeletionDatePreset: this.originalSendView.deletionDate.toString(), + password: this.hasPassword ? "************" : null, + authType: this.originalSendView.authType, + emails: this.originalSendView.emails?.join(", ") ?? null, }); + if (this.hasPassword) { + this.sendDetailsForm.get("password")?.disable(); + } + if (this.originalSendView.deletionDate) { this.customDeletionDateOption = { name: this.datePipe.transform(this.originalSendView.deletionDate, "short"), @@ -193,4 +303,61 @@ export class SendDetailsComponent implements OnInit { const milliseconds = now.setTime(now.getTime() + preset * 60 * 60 * 1000); return new Date(milliseconds).toString(); } + + emailListValidator(): ValidatorFn { + return (control: FormControl): ValidationErrors | null => { + if (!control.value) { + return null; + } + const emails = control.value.split(",").map((e: string) => e.trim()); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const invalidEmails = emails.filter((e: string) => e.length > 0 && !emailRegex.test(e)); + return invalidEmails.length > 0 ? { multipleEmails: true } : null; + }; + } + + generatePassword = async () => { + const on$ = new BehaviorSubject({ source: "send", type: Type.password }); + const account$ = this.accountService.activeAccount$.pipe( + pin({ name: () => "send-details.component", distinct: (p, c) => p.id === c.id }), + ); + const generatedCredential = await firstValueFrom( + this.generatorService.generate$({ on$, account$ }), + ); + + this.sendDetailsForm.patchValue({ + password: generatedCredential.credential, + }); + }; + + removePassword = async () => { + if (!this.originalSendView?.password) { + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "removePassword" }, + content: { key: "removePasswordConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + this.passwordRemoved = true; + + await this.sendApiService.removePassword(this.originalSendView.id); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("removedPassword"), + }); + + this.originalSendView.password = null; + this.sendDetailsForm.patchValue({ + password: null, + }); + this.sendDetailsForm.get("password")?.enable(); + }; } diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html index 855c37ecab5..6aaaf033e0d 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html @@ -38,14 +38,16 @@ } - - - + @if (cipher().edit) { + + + + }
  • @@ -54,46 +56,48 @@ }
    - - -
    - - - - - -

    - {{ "maxFileSizeSansPunctuation" | i18n }} -

    - +

    + {{ "maxFileSizeSansPunctuation" | i18n }} +

    + + } diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts index 2e54d3b539a..002ad019653 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts @@ -51,6 +51,7 @@ describe("CipherAttachmentsComponent", () => { username: "username", password: "password", }, + edit: true, } as CipherView; const cipherDomain = { @@ -197,6 +198,10 @@ describe("CipherAttachmentsComponent", () => { let file: File; beforeEach(() => { + const nonEditableCipherView = { ...cipherView, edit: false }; + cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView); + fixture.detectChanges(); + submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean); file = new File([""], "attachment.txt", { type: "text/plain" }); @@ -371,6 +376,32 @@ describe("CipherAttachmentsComponent", () => { expect(emitSpy).toHaveBeenCalled(); }); }); + + describe("close", () => { + async function setup(): Promise { + fixture = TestBed.createComponent(CipherAttachmentsComponent); + component = fixture.componentInstance; + submitBtnFixture = TestBed.createComponent(ButtonComponent); + + // Set organizationId BEFORE cipherId so the effect picks it up + fixture.componentRef.setInput("organizationId", organization.id); + fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance); + fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId); + await waitForInitialization(); + const nonEditableCipherView = { ...cipherView, edit: false }; + cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView); + fixture.detectChanges(); + } + + it('emits "onCloseButtonPress"', async () => { + await setup(); + const emitSpy = jest.spyOn(component.onCloseButtonPress, "emit"); + + await component.submit(); + + expect(emitSpy).toHaveBeenCalled(); + }); + }); }); describe("removeAttachment", () => { diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts index f75611b995e..e0a648e3107 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts @@ -105,6 +105,8 @@ export class CipherAttachmentsComponent { /** Emits after a file has been successfully removed */ readonly onRemoveSuccess = output(); + readonly onCloseButtonPress = output(); + protected readonly organization = signal(null); protected readonly cipher = signal(null); @@ -154,7 +156,7 @@ export class CipherAttachmentsComponent { // Update the initial state of the submit button const btn = this.submitBtn(); if (btn) { - btn.disabled.set(!this.attachmentForm.valid); + btn.disabled.set(!this.attachmentForm.valid && (this.cipher()?.edit ?? true)); } }); @@ -192,6 +194,12 @@ export class CipherAttachmentsComponent { /** Save the attachments to the cipher */ submit = async () => { + //user can't edit cipher and will close the bit-dialog + if (!(this.cipher()?.edit ?? false)) { + this.onCloseButtonPress.emit(); + return; + } + this.onUploadStarted.emit(); const file = this.attachmentForm.value.file; diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts index 59c583f980b..8566e51d74f 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts @@ -37,14 +37,13 @@ export class DefaultCipherFormService implements CipherFormService { // Creating a new cipher if (cipher.id == null || cipher.id === "") { - const encrypted = await this.cipherService.encrypt(cipher, activeUserId); - savedCipher = await this.cipherService.createWithServer(encrypted, config.admin); - return await this.cipherService.decrypt(savedCipher, activeUserId); + return await this.cipherService.createWithServer(cipher, activeUserId, config.admin); } if (config.originalCipher == null) { throw new Error("Original cipher is required for updating an existing cipher"); } + const originalCipherView = await this.decryptCipher(config.originalCipher); // Updating an existing cipher @@ -66,35 +65,31 @@ export class DefaultCipherFormService implements CipherFormService { ); // If the collectionIds are the same, update the cipher normally } else if (isSetEqual(originalCollectionIds, newCollectionIds)) { - const encrypted = await this.cipherService.encrypt( + const savedCipherView = await this.cipherService.updateWithServer( cipher, activeUserId, - null, - null, - config.originalCipher, + originalCipherView, + config.admin, ); - savedCipher = await this.cipherService.updateWithServer(encrypted, config.admin); + savedCipher = await this.cipherService + .encrypt(savedCipherView, activeUserId) + .then((res) => res.cipher); } else { - const encrypted = await this.cipherService.encrypt( - cipher, - activeUserId, - null, - null, - config.originalCipher, - ); - const encryptedCipher = encrypted.cipher; - // Updating a cipher with collection changes is not supported with a single request currently // First update the cipher with the original collectionIds - encryptedCipher.collectionIds = config.originalCipher.collectionIds; - await this.cipherService.updateWithServer( - encrypted, + cipher.collectionIds = config.originalCipher.collectionIds; + const newCipher = await this.cipherService.updateWithServer( + cipher, + activeUserId, + originalCipherView, config.admin || originalCollectionIds.size === 0, ); // Then save the new collection changes separately - encryptedCipher.collectionIds = cipher.collectionIds; + newCipher.collectionIds = cipher.collectionIds; + // TODO: Remove after migrating all SDK ops + const { cipher: encryptedCipher } = await this.cipherService.encrypt(newCipher, activeUserId); if (config.admin || originalCollectionIds.size === 0) { // When using an admin config or the cipher was unassigned, update collections as an admin savedCipher = await this.cipherService.saveCollectionsWithServerAdmin(encryptedCipher); diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.html b/libs/vault/src/cipher-view/attachments/attachments-v2.component.html index a8dc22c75ac..964fba6a266 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.html +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.html @@ -13,11 +13,12 @@ (onUploadSuccess)="uploadSuccessful()" (onUploadFailed)="uploadFailed()" (onRemoveSuccess)="removalSuccessful()" + (onCloseButtonPress)="closeButtonPressed()" > diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts index a188d673601..03ddb386ad0 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts @@ -69,4 +69,12 @@ describe("AttachmentsV2Component", () => { expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed }); }); + + it("closes the dialog with 'closed' result on closedButtonPressed", () => { + const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close"); + + component.closeButtonPressed(); + + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Closed }); + }); }); diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts index 218f5b2c6d3..9810aa929d6 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, HostListener, Inject } from "@angular/core"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { @@ -18,6 +19,7 @@ import { CipherAttachmentsComponent } from "../../cipher-form/components/attachm export interface AttachmentsDialogParams { cipherId: CipherId; + canEditCipher?: boolean; admin?: boolean; organizationId?: OrganizationId; } @@ -51,7 +53,9 @@ export class AttachmentsV2Component { cipherId: CipherId; admin: boolean = false; organizationId?: OrganizationId; + canEditCipher: boolean; attachmentFormId = CipherAttachmentsComponent.attachmentFormID; + buttonText: string; private isUploading = false; /** @@ -62,10 +66,14 @@ export class AttachmentsV2Component { constructor( private dialogRef: DialogRef, @Inject(DIALOG_DATA) public params: AttachmentsDialogParams, + private i18nService: I18nService, ) { this.cipherId = params.cipherId; this.organizationId = params.organizationId; this.admin = params.admin ?? false; + this.canEditCipher = params?.canEditCipher ?? false; + this.buttonText = + this.canEditCipher || this.admin ? this.i18nService.t("upload") : this.i18nService.t("close"); } /** @@ -140,4 +148,10 @@ export class AttachmentsV2Component { action: AttachmentDialogResult.Removed, }); } + + closeButtonPressed() { + this.dialogRef.close({ + action: AttachmentDialogResult.Closed, + }); + } } diff --git a/libs/vault/src/cipher-view/cipher-view.component.spec.ts b/libs/vault/src/cipher-view/cipher-view.component.spec.ts index 18a5132781b..2300565035e 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.spec.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.spec.ts @@ -8,7 +8,6 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -42,11 +41,9 @@ describe("CipherViewComponent", () => { let mockLogService: LogService; let mockCipherRiskService: CipherRiskService; let mockBillingAccountProfileStateService: BillingAccountProfileStateService; - let mockConfigService: ConfigService; // Mock data let mockCipherView: CipherView; - let featureFlagEnabled$: BehaviorSubject; let hasPremiumFromAnySource$: BehaviorSubject; let activeAccount$: BehaviorSubject; @@ -57,7 +54,6 @@ describe("CipherViewComponent", () => { email: "test@example.com", } as Account); - featureFlagEnabled$ = new BehaviorSubject(false); hasPremiumFromAnySource$ = new BehaviorSubject(true); // Create service mocks @@ -83,9 +79,6 @@ describe("CipherViewComponent", () => { .fn() .mockReturnValue(hasPremiumFromAnySource$); - mockConfigService = mock(); - mockConfigService.getFeatureFlag$ = jest.fn().mockReturnValue(featureFlagEnabled$); - // Setup mock cipher view mockCipherView = new CipherView(); mockCipherView.id = "cipher-id"; @@ -110,7 +103,6 @@ describe("CipherViewComponent", () => { provide: BillingAccountProfileStateService, useValue: mockBillingAccountProfileStateService, }, - { provide: ConfigService, useValue: mockConfigService }, ], schemas: [NO_ERRORS_SCHEMA], }) @@ -145,7 +137,6 @@ describe("CipherViewComponent", () => { beforeEach(() => { // Reset observables to default values for this test suite - featureFlagEnabled$.next(true); hasPremiumFromAnySource$.next(true); // Setup default mock for computeCipherRiskForUser (individual tests can override) @@ -162,18 +153,6 @@ describe("CipherViewComponent", () => { component = fixture.componentInstance; }); - it("returns false when feature flag is disabled", fakeAsync(() => { - featureFlagEnabled$.next(false); - - const cipher = createLoginCipherView(); - fixture.componentRef.setInput("cipher", cipher); - fixture.detectChanges(); - tick(); - - expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled(); - expect(component.passwordIsAtRisk()).toBe(false); - })); - it("returns false when cipher has no login password", fakeAsync(() => { const cipher = createLoginCipherView(); cipher.login = {} as any; // No password diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index b5c063df51e..26e3f18b542 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -13,8 +13,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { isCardExpired } from "@bitwarden/common/autofill/utils"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getByIds } from "@bitwarden/common/platform/misc"; @@ -113,7 +111,6 @@ export class CipherViewComponent { private logService: LogService, private cipherRiskService: CipherRiskService, private billingAccountService: BillingAccountProfileStateService, - private configService: ConfigService, ) {} readonly resolvedCollections = toSignal( @@ -248,19 +245,9 @@ export class CipherViewComponent { * The password is only evaluated when the user is premium and has edit access to the cipher. */ readonly passwordIsAtRisk = toSignal( - combineLatest([ - this.activeUserId$, - this.cipher$, - this.configService.getFeatureFlag$(FeatureFlag.RiskInsightsForPremium), - ]).pipe( - switchMap(([userId, cipher, featureEnabled]) => { - if ( - !featureEnabled || - !cipher.hasLoginPassword || - !cipher.edit || - cipher.organizationId || - cipher.isDeleted - ) { + combineLatest([this.activeUserId$, this.cipher$]).pipe( + switchMap(([userId, cipher]) => { + if (!cipher.hasLoginPassword || !cipher.edit || cipher.organizationId || cipher.isDeleted) { return of(false); } return this.switchPremium$( diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html index 7af120cfd6c..913d1b7963b 100644 --- a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html @@ -9,5 +9,5 @@ [attr.aria-label]="slide.label" (click)="onClick.emit()" > - + diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts index bef7f5b12d6..42fe082d5f8 100644 --- a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from "@angular/core"; import { CarouselIcon } from "@bitwarden/assets/svg"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.component"; @@ -12,7 +12,7 @@ import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.co @Component({ selector: "vault-carousel-button", templateUrl: "carousel-button.component.html", - imports: [CommonModule, IconModule], + imports: [CommonModule, SvgModule], }) export class VaultCarouselButtonComponent implements FocusableOption { /** Slide component that is associated with the individual button */ diff --git a/libs/vault/src/components/carousel/carousel.component.spec.ts b/libs/vault/src/components/carousel/carousel.component.spec.ts index abbfe963ddf..eb9480398e9 100644 --- a/libs/vault/src/components/carousel/carousel.component.spec.ts +++ b/libs/vault/src/components/carousel/carousel.component.spec.ts @@ -1,4 +1,4 @@ -import { Component } from "@angular/core"; +import { Component, ChangeDetectionStrategy } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; @@ -7,11 +7,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component"; import { VaultCarouselComponent } from "./carousel.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-test-carousel-slide", imports: [VaultCarouselComponent, VaultCarouselSlideComponent], + changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -93,8 +92,7 @@ describe("VaultCarouselComponent", () => { const backButton = fixture.debugElement.queryAll(By.css("button"))[0]; middleSlideButton.nativeElement.click(); - await new Promise((r) => setTimeout(r, 100)); // Give time for the DOM to update. - + fixture.detectChanges(); jest.spyOn(component.slideChange, "emit"); backButton.nativeElement.click(); diff --git a/libs/vault/src/components/carousel/carousel.component.ts b/libs/vault/src/components/carousel/carousel.component.ts index 4e180f09f9b..c622f2e5d85 100644 --- a/libs/vault/src/components/carousel/carousel.component.ts +++ b/libs/vault/src/components/carousel/carousel.component.ts @@ -22,7 +22,6 @@ import { take } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ButtonModule, IconButtonModule } from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button.component"; import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component"; @@ -41,7 +40,6 @@ import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.com ButtonModule, VaultCarouselContentComponent, VaultCarouselButtonComponent, - I18nPipe, ], }) export class VaultCarouselComponent implements AfterViewInit { diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.html b/libs/vault/src/components/download-attachment/download-attachment.component.html index 9d80f36818a..c6665c5d569 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.html +++ b/libs/vault/src/components/download-attachment/download-attachment.component.html @@ -5,6 +5,6 @@ buttonType="main" size="small" type="button" - [label]="'downloadAttachmentName' | i18n: attachment().fileName" + [label]="'downloadAttachmentLabel' | i18n" > } diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts index 3bbc375fdfc..a46ce28fca8 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts @@ -108,7 +108,7 @@ describe("DownloadAttachmentComponent", () => { it("renders delete button", () => { const deleteButton = fixture.debugElement.query(By.css("button")); - expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentName"); + expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentLabel"); }); describe("download attachment", () => { diff --git a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts index 51154c3cee9..f5da99cae61 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts @@ -16,6 +16,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -43,6 +44,7 @@ describe("DefaultVaultItemsTransferService", () => { let mockEventCollectionService: MockProxy; let mockConfigService: MockProxy; let mockOrganizationUserApiService: MockProxy; + let mockSyncService: MockProxy; const userId = "user-id" as UserId; const organizationId = "org-id" as OrganizationId; @@ -79,6 +81,7 @@ describe("DefaultVaultItemsTransferService", () => { mockEventCollectionService = mock(); mockConfigService = mock(); mockOrganizationUserApiService = mock(); + mockSyncService = mock(); mockI18nService.t.mockImplementation((key) => key); transferInProgressValues = []; @@ -95,6 +98,7 @@ describe("DefaultVaultItemsTransferService", () => { mockEventCollectionService, mockConfigService, mockOrganizationUserApiService, + mockSyncService, ); }); @@ -557,6 +561,8 @@ describe("DefaultVaultItemsTransferService", () => { mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? [])); mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? [])); mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection)); + mockSyncService.fullSync.mockResolvedValue(true); + mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined); } it("does nothing when feature flag is disabled", async () => { @@ -635,11 +641,11 @@ describe("DefaultVaultItemsTransferService", () => { mockDialogService.open .mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined)) .mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed)); - mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined); await service.enforceOrganizationDataOwnership(userId); expect(mockOrganizationUserApiService.revokeSelf).toHaveBeenCalledWith(organizationId); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); expect(mockToastService.showToast).toHaveBeenCalledWith({ variant: "success", message: "leftOrganization", diff --git a/libs/vault/src/services/default-vault-items-transfer.service.ts b/libs/vault/src/services/default-vault-items-transfer.service.ts index 6009fc97e7c..3e65d3157f5 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.ts @@ -23,6 +23,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { getById } from "@bitwarden/common/platform/misc"; import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -54,6 +55,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi private eventCollectionService: EventCollectionService, private configService: ConfigService, private organizationUserApiService: OrganizationUserApiService, + private syncService: SyncService, ) {} private _transferInProgressSubject = new BehaviorSubject(false); @@ -164,7 +166,6 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi if (!userAcceptedTransfer) { await this.organizationUserApiService.revokeSelf(migrationInfo.enforcingOrganization.id); - this.toastService.showToast({ variant: "success", message: this.i18nService.t("leftOrganization"), @@ -176,6 +177,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi undefined, migrationInfo.enforcingOrganization.id, ); + // Sync to reflect organization removal + await this.syncService.fullSync(true); return; } diff --git a/libs/vault/src/services/vault-filter.service.ts b/libs/vault/src/services/vault-filter.service.ts index b21e140e023..445764827eb 100644 --- a/libs/vault/src/services/vault-filter.service.ts +++ b/libs/vault/src/services/vault-filter.service.ts @@ -26,6 +26,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { cloneCollection } from "@bitwarden/common/admin-console/utils/collection-utils"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -184,7 +185,14 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { const orgNodes: TreeNode[] = []; orgs.forEach((org) => { const orgCopy = org as OrganizationFilter; - orgCopy.icon = "bwi-business"; + if ( + org?.productTierType === ProductTierType.Free || + org?.productTierType === ProductTierType.Families + ) { + orgCopy.icon = "bwi-family"; + } else { + orgCopy.icon = "bwi-business"; + } const node = new TreeNode(orgCopy, headNode, orgCopy.name); orgNodes.push(node); }); diff --git a/package-lock.json b/package-lock.json index ff632dc2807..da9b3e7dcbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,17 +14,17 @@ "libs/**/*" ], "dependencies": { - "@angular/animations": "20.3.15", + "@angular/animations": "20.3.16", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.15", - "@angular/compiler": "20.3.15", - "@angular/core": "20.3.15", - "@angular/forms": "20.3.15", - "@angular/platform-browser": "20.3.15", - "@angular/platform-browser-dynamic": "20.3.15", - "@angular/router": "20.3.15", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.470", - "@bitwarden/sdk-internal": "0.2.0-main.470", + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/forms": "20.3.16", + "@angular/platform-browser": "20.3.16", + "@angular/platform-browser-dynamic": "20.3.16", + "@angular/router": "20.3.16", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.506", + "@bitwarden/sdk-internal": "0.2.0-main.506", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -52,7 +52,7 @@ "lunr": "2.3.9", "multer": "2.0.2", "ngx-toastr": "19.1.0", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "oidc-client-ts": "2.4.1", "open": "11.0.0", @@ -74,7 +74,7 @@ "@angular-devkit/build-angular": "20.3.12", "@angular-eslint/schematics": "20.7.0", "@angular/cli": "20.3.12", - "@angular/compiler-cli": "20.3.15", + "@angular/compiler-cli": "20.3.16", "@babel/core": "7.28.5", "@babel/preset-env": "7.28.5", "@compodoc/compodoc": "1.1.32", @@ -106,11 +106,11 @@ "@types/koa__multer": "2.0.7", "@types/koa__router": "12.0.4", "@types/koa-bodyparser": "4.3.7", - "@types/koa-json": "2.0.23", + "@types/koa-json": "2.0.24", "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", - "@types/node": "22.19.3", - "@types/node-fetch": "2.6.4", + "@types/node": "22.19.7", + "@types/node-fetch": "2.6.13", "@types/node-forge": "1.3.14", "@types/papaparse": "5.5.0", "@types/proper-lockfile": "4.1.4", @@ -127,7 +127,7 @@ "base64-loader": "1.0.0", "browserslist": "4.28.1", "chromatic": "13.3.4", - "concurrently": "9.2.0", + "concurrently": "9.2.1", "copy-webpack-plugin": "13.0.1", "cross-env": "10.1.0", "css-loader": "7.1.2", @@ -192,11 +192,11 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2025.12.1" + "version": "2026.1.0" }, "apps/cli": { "name": "@bitwarden/cli", - "version": "2025.12.1", + "version": "2026.1.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@koa/multer": "4.0.0", @@ -217,7 +217,7 @@ "lowdb": "1.0.0", "lunr": "2.3.9", "multer": "2.0.2", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "open": "11.0.0", "papaparse": "5.5.3", @@ -278,7 +278,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.12.1", + "version": "2026.1.0", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -491,7 +491,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2026.1.0" + "version": "2026.1.1" }, "libs/admin-console": { "name": "@bitwarden/admin-console", @@ -2203,9 +2203,9 @@ } }, "node_modules/@angular/animations": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.15.tgz", - "integrity": "sha512-ikyKfhkxoqQA6JcBN0B9RaN6369sM1XYX81Id0lI58dmWCe7gYfrTp8ejqxxKftl514psQO3pkW8Gn1nJ131Gw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.16.tgz", + "integrity": "sha512-N83/GFY5lKNyWgPV3xHHy2rb3/eP1ZLzSVI+dmMVbf3jbqwY1YPQcMiAG8UDzaILY1Dkus91kWLF8Qdr3nHAzg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2214,7 +2214,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.15" + "@angular/core": "20.3.16" } }, "node_modules/@angular/build": { @@ -2627,9 +2627,9 @@ } }, "node_modules/@angular/common": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.15.tgz", - "integrity": "sha512-k4mCXWRFiOHK3bUKfWkRQQ8KBPxW8TAJuKLYCsSHPCpMz6u0eA1F0VlrnOkZVKWPI792fOaEAWH2Y4PTaXlUHw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.16.tgz", + "integrity": "sha512-GRAziNlntwdnJy3F+8zCOvDdy7id0gITjDnM6P9+n2lXvtDuBLGJKU3DWBbvxcCjtD6JK/g/rEX5fbCxbUHkQQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2638,14 +2638,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.15", + "@angular/core": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.15.tgz", - "integrity": "sha512-lMicIAFAKZXa+BCZWs3soTjNQPZZXrF/WMVDinm8dQcggNarnDj4UmXgKSyXkkyqK5SLfnLsXVzrX6ndVT6z7A==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.16.tgz", + "integrity": "sha512-Pt9Ms9GwTThgzdxWBwMfN8cH1JEtQ2DK5dc2yxYtPSaD+WKmG9AVL1PrzIYQEbaKcWk2jxASUHpEWSlNiwo8uw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2655,9 +2655,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.15.tgz", - "integrity": "sha512-8sJoxodxsfyZ8eJ5r6Bx7BCbazXYgsZ1+dE8t5u5rTQ6jNggwNtYEzkyReoD5xvP+MMtRkos3xpwq4rtFnpI6A==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.16.tgz", + "integrity": "sha512-l3xF/fXfJAl/UrNnH9Ufkr79myjMgXdHq1mmmph2UnpeqilRB1b8lC9sLBV9MipQHVn3dwocxMIvtrcryfOaXw==", "dev": true, "license": "MIT", "dependencies": { @@ -2678,7 +2678,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.15", + "@angular/compiler": "20.3.16", "typescript": ">=5.8 <6.0" }, "peerDependenciesMeta": { @@ -2864,9 +2864,9 @@ } }, "node_modules/@angular/core": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.15.tgz", - "integrity": "sha512-NMbX71SlTZIY9+rh/SPhRYFJU0pMJYW7z/TBD4lqiO+b0DTOIg1k7Pg9ydJGqSjFO1Z4dQaA6TteNuF99TJCNw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.16.tgz", + "integrity": "sha512-KSFPKvOmWWLCJBbEO+CuRUXfecX2FRuO0jNi9c54ptXMOPHlK1lIojUnyXmMNzjdHgRug8ci9qDuftvC2B7MKg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2875,7 +2875,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.15", + "@angular/compiler": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0" }, @@ -2889,9 +2889,9 @@ } }, "node_modules/@angular/forms": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.15.tgz", - "integrity": "sha512-gS5hQkinq52pm/7mxz4yHPCzEcmRWjtUkOVddPH0V1BW/HMni/p4Y6k2KqKBeGb9p8S5EAp6PDxDVLOPukp3mg==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.16.tgz", + "integrity": "sha512-1yzbXpExTqATpVcqA3wGrq4ACFIP3mRxA4pbso5KoJU+/4JfzNFwLsDaFXKpm5uxwchVnj8KM2vPaDOkvtp7NA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2900,16 +2900,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.15.tgz", - "integrity": "sha512-TxRM/wTW/oGXv/3/Iohn58yWoiYXOaeEnxSasiGNS1qhbkcKtR70xzxW6NjChBUYAixz2ERkLURkpx3pI8Q6Dw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.16.tgz", + "integrity": "sha512-YsrLS6vyS77i4pVHg4gdSBW74qvzHjpQRTVQ5Lv/OxIjJdYYYkMmjNalCNgy1ZuyY6CaLIB11ccxhrNnxfKGOQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2918,9 +2918,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "20.3.15", - "@angular/common": "20.3.15", - "@angular/core": "20.3.15" + "@angular/animations": "20.3.16", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16" }, "peerDependenciesMeta": { "@angular/animations": { @@ -2929,9 +2929,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.15.tgz", - "integrity": "sha512-RizuRdBt0d6ongQ2y8cr8YsXFyjF8f91vFfpSNw+cFj+oiEmRC1txcWUlH5bPLD9qSDied8qazUi0Tb8VPQDGw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.16.tgz", + "integrity": "sha512-5mECCV9YeKH6ue239GXRTGeDSd/eTbM1j8dDejhm5cGnPBhTxRw4o+GgSrWTYtb6VmIYdwUGBTC+wCBphiaQ2A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2940,16 +2940,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/compiler": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15" + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16" } }, "node_modules/@angular/router": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.15.tgz", - "integrity": "sha512-6+qgk8swGSoAu7ISSY//GatAyCP36hEvvUgvjbZgkXLLH9yUQxdo77ij05aJ5s0OyB25q/JkqS8VTY0z1yE9NQ==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz", + "integrity": "sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2958,9 +2958,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -4982,10 +4982,9 @@ "link": true }, "node_modules/@bitwarden/commercial-sdk-internal": { - "version": "0.2.0-main.470", - "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.470.tgz", - "integrity": "sha512-QYhxv5eX6ouFJv94gMtBW7MjuK6t2KAN9FLz+/w1wnq8dScnA9Iky25phNPw+iHMgWwhq/dzZq45asKUFF//oA==", - "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", + "version": "0.2.0-main.506", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.506.tgz", + "integrity": "sha512-aRzcxOcj8vXxz0jN3q2xxj26zxBfjg3oRm5QXbWE7zXJ2PGrgxTaePca9pQYYpwgr7iufYMnZcq5dH+qttNEmA==", "dependencies": { "type-fest": "^4.41.0" } @@ -5087,10 +5086,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.470", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.470.tgz", - "integrity": "sha512-XKvcUtoU6NnxeEzl3WK7bATiCh2RNxRmuX6JYNgcQHUtHUH+x3ckToR6II1qM3nha0VH0u1ijy3+07UdNQM+JQ==", - "license": "GPL-3.0", + "version": "0.2.0-main.506", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.506.tgz", + "integrity": "sha512-BbTSU5Acx74Hr32zDj2kV8sbdclyvdIti5t6kXnCvJmA5dZbu+5j5Xw1luS9mGL9Vfi4w3OjVug/TiSxyhwLzQ==", "dependencies": { "type-fest": "^4.41.0" } @@ -15768,9 +15766,9 @@ } }, "node_modules/@types/koa-json": { - "version": "2.0.23", - "resolved": "https://registry.npmjs.org/@types/koa-json/-/koa-json-2.0.23.tgz", - "integrity": "sha512-LJKLFouztosawgU5xrtanK4neLCQKXl+vuVN96YMeVdKTYObLq2Qybggm9V426Jwam8Gi/zOrPw1g+QH0VaEHw==", + "version": "2.0.24", + "resolved": "https://registry.npmjs.org/@types/koa-json/-/koa-json-2.0.24.tgz", + "integrity": "sha512-FF+nQil6YO8vXMuLnOgGHYspSZVVpi+W79m9/s7LBSOQhlX7QY02X3Evk/g1GgWNLbO674AQaziX6OCCKzQ6Aw==", "dev": true, "license": "MIT", "dependencies": { @@ -15833,62 +15831,23 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", - "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, "node_modules/@types/node-fetch": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz", - "integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==", + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "form-data": "^3.0.0" - } - }, - "node_modules/@types/node-fetch/node_modules/form-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.3.tgz", - "integrity": "sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@types/node-fetch/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@types/node-fetch/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" + "form-data": "^4.0.4" } }, "node_modules/@types/node-forge": { @@ -20597,19 +20556,18 @@ } }, "node_modules/concurrently": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", - "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.1.2", - "lodash": "^4.17.21", - "rxjs": "^7.8.1", - "shell-quote": "^1.8.1", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.2" + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", @@ -20622,6 +20580,16 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/concurrently/node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/concurrently/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -32453,9 +32421,9 @@ "license": "MIT" }, "node_modules/msgpackr": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", - "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.8.tgz", + "integrity": "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==", "dev": true, "license": "MIT", "optional": true, @@ -32816,9 +32784,9 @@ } }, "node_modules/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -34729,9 +34697,9 @@ } }, "node_modules/ordered-binary": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.0.tgz", - "integrity": "sha512-IQh2aMfMIDbPjI/8a3Edr+PiOpcsB7yo8NdW7aHWVaoR/pcDldunMvnnwbk/auPGqmKeAdxtZl7MHX/QmPwhvQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", "dev": true, "license": "MIT", "optional": true diff --git a/package.json b/package.json index 829dc91370a..20ca9b20f8e 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@angular-devkit/build-angular": "20.3.12", "@angular-eslint/schematics": "20.7.0", "@angular/cli": "20.3.12", - "@angular/compiler-cli": "20.3.15", + "@angular/compiler-cli": "20.3.16", "@babel/core": "7.28.5", "@babel/preset-env": "7.28.5", "@compodoc/compodoc": "1.1.32", @@ -73,11 +73,11 @@ "@types/koa__multer": "2.0.7", "@types/koa__router": "12.0.4", "@types/koa-bodyparser": "4.3.7", - "@types/koa-json": "2.0.23", + "@types/koa-json": "2.0.24", "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", - "@types/node": "22.19.3", - "@types/node-fetch": "2.6.4", + "@types/node": "22.19.7", + "@types/node-fetch": "2.6.13", "@types/node-forge": "1.3.14", "@types/papaparse": "5.5.0", "@types/proper-lockfile": "4.1.4", @@ -94,7 +94,7 @@ "base64-loader": "1.0.0", "browserslist": "4.28.1", "chromatic": "13.3.4", - "concurrently": "9.2.0", + "concurrently": "9.2.1", "copy-webpack-plugin": "13.0.1", "cross-env": "10.1.0", "css-loader": "7.1.2", @@ -153,17 +153,17 @@ "webpack-node-externals": "3.0.0" }, "dependencies": { - "@angular/animations": "20.3.15", + "@angular/animations": "20.3.16", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.15", - "@angular/compiler": "20.3.15", - "@angular/core": "20.3.15", - "@angular/forms": "20.3.15", - "@angular/platform-browser": "20.3.15", - "@angular/platform-browser-dynamic": "20.3.15", - "@angular/router": "20.3.15", - "@bitwarden/sdk-internal": "0.2.0-main.470", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.470", + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/forms": "20.3.16", + "@angular/platform-browser": "20.3.16", + "@angular/platform-browser-dynamic": "20.3.16", + "@angular/router": "20.3.16", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.506", + "@bitwarden/sdk-internal": "0.2.0-main.506", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -191,7 +191,7 @@ "lunr": "2.3.9", "multer": "2.0.2", "ngx-toastr": "19.1.0", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "oidc-client-ts": "2.4.1", "open": "11.0.0",